Merge branch 'main' into feature/scheduled-send
# Conflicts: # app/(main)/[locale]/page.tsx # components/email/email-list.tsx # components/email/email-viewer.tsx
This commit is contained in:
@@ -157,7 +157,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesBefore && "-ml-0.5",
|
||||
continuesAfter && "pr-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -143,6 +143,8 @@ export function EventDetailPopover({
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = getEventEndDate(event);
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
@@ -331,16 +333,50 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
{isMultiDay ? (
|
||||
event.showWithoutTime ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)} –
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(displayEndDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(startDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(endDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -621,14 +621,42 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium">{formatEventDate(displayEnd)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
@@ -742,17 +770,55 @@ export function EventModal({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durMin)})
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -121,9 +122,11 @@ export function EmailList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
@@ -76,6 +77,7 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
@@ -954,6 +956,7 @@ export function EmailViewer({
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('reschedule_prompt'));
|
||||
if (!value) return null;
|
||||
@@ -3301,9 +3304,9 @@ export function EmailViewer({
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = resolvedTheme === 'dark'
|
||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
@@ -5384,10 +5387,12 @@ export function EmailViewer({
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
name={currentUserName || "You"}
|
||||
email={currentUserEmail || ""}
|
||||
name={activeAccount?.displayName || currentUserName || "You"}
|
||||
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
|
||||
size="lg"
|
||||
className="shadow-sm w-10 h-10"
|
||||
disableFavicon
|
||||
fallbackColor={activeAccount?.avatarColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
|
||||
@@ -74,7 +74,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
|
||||
@@ -95,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -404,7 +404,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
@@ -444,7 +444,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -736,15 +736,20 @@ export function Sidebar({
|
||||
// pane.
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
||||
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
||||
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
|
||||
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||
// 2+ account requirement still applies - with a single account the
|
||||
// unified counts would just duplicate that account's inbox.
|
||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
|
||||
// single account we still surface unified when the user has opted into
|
||||
// merging group/shared inboxes — otherwise the counts would just duplicate
|
||||
// the one inbox.
|
||||
const showUnified =
|
||||
(multiAccountMode || enableUnifiedMailbox) &&
|
||||
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoSrc = appLogoLightUrl || faviconUrl;
|
||||
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
|
||||
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
|
||||
)}
|
||||
{logoSrc && (
|
||||
<img
|
||||
src={appLogoDarkUrl || faviconUrl}
|
||||
src={darkLogoSrc}
|
||||
alt={appName}
|
||||
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||
/>
|
||||
|
||||
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const t = useTranslations('calendar.settings');
|
||||
const tViews = useTranslations('calendar.views');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { viewMode, setViewMode } = useCalendarStore();
|
||||
const {
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
showTimeInMonthView,
|
||||
showWeekNumbers,
|
||||
enableCalendarTasks,
|
||||
@@ -40,28 +37,6 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('week_starts_on')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format_12h') },
|
||||
{ value: '24h', label: t('time_format_24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_time_in_month_view')}
|
||||
description={t('show_time_in_month_view_desc')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "sm")
|
||||
) : null}
|
||||
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "lg")
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const t = useTranslations('settings.language_region');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
|
||||
|
||||
// Subscribe to locale changes so the preview re-renders on language switch
|
||||
// (formatDate reads it via getState() and would otherwise stay stale).
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
// Build sample timestamps for each bucket so users see what their pick
|
||||
// will look like in practice. Use offsets relative to "now" so the
|
||||
// bucketing is stable even though the wall-clock keeps moving.
|
||||
void locale; void dateFormat; void timeFormat;
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setHours(15, 31, 0, 0);
|
||||
const thisWeek = new Date(now);
|
||||
thisWeek.setDate(now.getDate() - 2);
|
||||
thisWeek.setHours(15, 31, 0, 0);
|
||||
const older = new Date(now);
|
||||
older.setMonth(now.getMonth() - 2);
|
||||
older.setHours(15, 31, 0, 0);
|
||||
return {
|
||||
today: formatDate(today),
|
||||
thisWeek: formatDate(thisWeek),
|
||||
older: formatDate(older),
|
||||
};
|
||||
}, [locale, dateFormat, timeFormat]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('language.label')} description={t('language.description')}>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onChange={(value) => updateSetting('dateFormat', value as DateFormat)}
|
||||
options={[
|
||||
{ value: 'smart', label: t('date_format.smart') },
|
||||
{ value: 'relative', label: t('date_format.relative') },
|
||||
{ value: 'full', label: t('date_format.full') },
|
||||
]}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_today')} </span>
|
||||
<span className="text-foreground/90">{preview.today}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
|
||||
<span className="text-foreground/90">{preview.thisWeek}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_older')} </span>
|
||||
<span className="text-foreground/90">{preview.older}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format.12h') },
|
||||
{ value: '24h', label: t('time_format.24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -115,9 +117,11 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -177,10 +181,11 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
locked={isSettingLocked('enableUnifiedMailbox')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
@@ -189,6 +194,21 @@ export function LayoutSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.include_group.label')}
|
||||
description={t('unified_mailbox.include_group.description')}
|
||||
locked={isSettingLocked('includeGroupInUnified')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={includeGroupInUnified}
|
||||
onChange={(v) => updateSetting('includeGroupInUnified', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user