From 2547c100601b2e2d0fccf497bd80e903e8d33a4a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:38:42 +0100 Subject: [PATCH 1/7] feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface - Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`. - Added demo file nodes representing directories and files in `files.ts`. - Implemented demo Sieve capabilities and scripts in `filters.ts`. - Defined demo identities for users in `identities.ts`. - Established demo mailboxes with permissions and counts in `mailboxes.ts`. - Created a demo vacation response in `vacation.ts`. - Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API. --- app/[locale]/calendar/page.tsx | 1 + app/[locale]/contacts/page.tsx | 1 + app/[locale]/layout.tsx | 5 +- app/[locale]/login/page.tsx | 195 ++++- app/[locale]/page.tsx | 5 +- app/[locale]/settings/page.tsx | 2 +- app/api/config/route.ts | 1 + .../calendar/calendar-sidebar-panel.tsx | 4 +- components/calendar/calendar-toolbar.tsx | 2 +- components/calendar/event-modal.tsx | 2 +- components/calendar/ical-import-modal.tsx | 4 +- .../calendar/ical-subscription-modal.tsx | 4 +- components/email/email-composer.tsx | 1 + components/email/email-list.tsx | 2 +- components/email/email-viewer.tsx | 54 +- components/keyboard-shortcuts-modal.tsx | 10 + components/layout/navigation-rail.tsx | 7 +- components/layout/sidebar.tsx | 78 +- components/settings/account-settings.tsx | 20 +- components/settings/appearance-settings.tsx | 18 + components/tour/tour-overlay.tsx | 413 +++++++++ components/tour/tour-provider.tsx | 148 ++++ components/tour/tour-steps.ts | 221 +++++ components/ui/welcome-banner.tsx | 13 +- hooks/use-config.ts | 4 + lib/demo/demo-client.ts | 800 ++++++++++++++++++ lib/demo/demo-data.ts | 45 + lib/demo/demo-utils.ts | 35 + lib/demo/fixtures/calendars.ts | 274 ++++++ lib/demo/fixtures/contacts.ts | 194 +++++ lib/demo/fixtures/emails.ts | 364 ++++++++ lib/demo/fixtures/files.ts | 94 ++ lib/demo/fixtures/filters.ts | 48 ++ lib/demo/fixtures/identities.ts | 22 + lib/demo/fixtures/mailboxes.ts | 17 + lib/demo/fixtures/vacation.ts | 13 + lib/jmap/client-interface.ts | 228 +++++ lib/jmap/client.ts | 3 +- locales/de/common.json | 67 +- locales/en/common.json | 73 +- locales/es/common.json | 67 +- locales/fr/common.json | 67 +- locales/it/common.json | 67 +- locales/ja/common.json | 67 +- locales/nl/common.json | 67 +- locales/pt/common.json | 67 +- stores/auth-store.ts | 104 ++- stores/calendar-store.ts | 32 +- stores/contact-store.ts | 30 +- stores/email-store.ts | 64 +- stores/file-store.ts | 8 +- stores/filter-store.ts | 8 +- stores/vacation-store.ts | 6 +- 53 files changed, 4036 insertions(+), 110 deletions(-) create mode 100644 components/tour/tour-overlay.tsx create mode 100644 components/tour/tour-provider.tsx create mode 100644 components/tour/tour-steps.ts create mode 100644 lib/demo/demo-client.ts create mode 100644 lib/demo/demo-data.ts create mode 100644 lib/demo/demo-utils.ts create mode 100644 lib/demo/fixtures/calendars.ts create mode 100644 lib/demo/fixtures/contacts.ts create mode 100644 lib/demo/fixtures/emails.ts create mode 100644 lib/demo/fixtures/files.ts create mode 100644 lib/demo/fixtures/filters.ts create mode 100644 lib/demo/fixtures/identities.ts create mode 100644 lib/demo/fixtures/mailboxes.ts create mode 100644 lib/demo/fixtures/vacation.ts create mode 100644 lib/jmap/client-interface.ts diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 19e41bbc..671b7ffe 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -795,6 +795,7 @@ export default function CalendarPage() {
diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index a7f47981..5f94ff13 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -606,6 +606,7 @@ export default function ContactsPage() { {/* Panel 2: Contact list */}
- {children} + + {children} + diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index c527bbbf..4e7e7b94 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; import { cn } from "@/lib/utils"; -import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react"; +import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; @@ -30,9 +30,9 @@ export default function LoginPage() { const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; - const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); + const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); + const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ @@ -54,6 +54,7 @@ export default function LoginPage() { const [oauthMetadata, setOauthMetadata] = useState(null); const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); const [oauthLoading, setOauthLoading] = useState(false); + const [demoLoading, setDemoLoading] = useState(false); const suggestionsRef = useRef(null); const inputRef = useRef(null); @@ -206,7 +207,7 @@ export default function LoginPage() { ); } - if (!serverUrl) { + if (!serverUrl && !demoMode) { return (
@@ -353,9 +354,167 @@ export default function LoginPage() { } }; + const handleDemoLogin = async () => { + setDemoLoading(true); + const success = await loginDemo(); + if (success) { + router.push('/'); + } + setDemoLoading(false); + }; + const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2]; const CurrentThemeIcon = currentThemeOption.icon; + // Demo-only mode: show only a large demo login button + if (demoMode && !isAddAccountMode) { + return ( +
+ {/* Theme toggle */} +
+ + + {showThemeMenu && ( +
+ {THEME_OPTIONS.map((option) => { + const Icon = option.icon; + const isActive = theme === option.value; + return ( + + ); + })} +
+ )} +
+ +
+
+ {/* Header with logo */} +
+
+ {appName} +
+

+ {appName} +

+

+ {t("demo_tagline")} +

+
+ + {/* Large demo button */} +
+ {error && ( +
+ +

+ {t(`error.${error}`) || t("error.generic")} +

+
+ )} + + + +

+ {t("demo_no_signup")} +

+
+
+ + {/* Footer */} +
+ {loginCompanyName && ( +

+ {loginCompanyName} +

+ )} + {(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && ( +
+ {loginWebsiteUrl && ( + + {t("website")} + + )} + {loginImprintUrl && ( + + {t("imprint")} + + )} + {loginPrivacyPolicyUrl && ( + + {t("privacy_policy")} + + )} +
+ )} +

+ v{APP_VERSION} +

+
+
+
+ ); + } + return (
{/* Theme toggle - top right, dropdown style */} @@ -747,6 +906,34 @@ export default function LoginPage() {
)} + + {/* Demo Mode Button */} + {demoMode && !isAddAccountMode && ( +
+ +

+ {t("demo_description")} +

+
+ )}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 520fbb56..c0b39e5a 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1174,6 +1174,7 @@ export default function Home() { onChange={(e) => setSearchQuery(e.target.value)} className={cn("pl-9 h-9", searchQuery && "pr-8")} data-search-input + data-tour="search-input" /> {searchQuery && (
{/* Tabs */} -
+
{groupedTabs.map((group, groupIndex) => (
diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 40f18a04..5558f4ba 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -35,5 +35,6 @@ export async function GET() { loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '', loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', + demoMode: process.env.DEMO_MODE === 'true', }); } diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 266c0809..d8aba289 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -9,7 +9,7 @@ import { CalendarColorPicker } from "@/components/settings/calendar-management-s import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; -import type { JMAPClient } from "@/lib/jmap/client"; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; interface CalendarSidebarPanelProps { calendars: Calendar[]; @@ -17,7 +17,7 @@ interface CalendarSidebarPanelProps { onToggleVisibility: (id: string) => void; onColorChange?: (calendarId: string, color: string) => void; onSubscribe?: () => void; - client?: JMAPClient | null; + client?: IJMAPClient | null; } export function CalendarSidebarPanel({ diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 3f88d83a..c9db0990 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -326,7 +326,7 @@ export function CalendarToolbar({ )} {!isMobile && ( - diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 61961bbf..b6a8874d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -729,7 +729,7 @@ export function EventModal({ } return ( -
+

{isEdit ? t("events.edit") : t("events.create")} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 3429b7c5..2e9657d4 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -6,14 +6,14 @@ import { Button } from "@/components/ui/button"; import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react"; import { format, parseISO } from "date-fns"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; -import type { JMAPClient } from "@/lib/jmap/client"; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; interface ICalImportModalProps { calendars: Calendar[]; - client: JMAPClient; + client: IJMAPClient; onClose: () => void; } diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index 23ef58d9..226c9265 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { X, Loader2, Globe } from "lucide-react"; -import type { JMAPClient } from "@/lib/jmap/client"; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { useCalendarStore } from "@/stores/calendar-store"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { toast } from "@/stores/toast-store"; interface ICalSubscriptionModalProps { - client: JMAPClient; + client: IJMAPClient; onClose: () => void; } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 509ebc75..85a4480b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -886,6 +886,7 @@ export function EmailComposer({ return (
+
{/* Loading overlay */} {isLoading && emails.length > 0 && (
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b4ccd9d3..550f569b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -66,6 +66,7 @@ import { Moon, HelpCircle, EditIcon, + PlayCircle, } from "lucide-react"; import { useTranslations } from "next-intl"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; @@ -80,6 +81,7 @@ import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; +import { useTour } from "@/components/tour/tour-provider"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { findCalendarAttachment } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; @@ -871,6 +873,8 @@ export function EmailViewer({ const tCommon = useTranslations('common'); const tSmime = useTranslations('smime'); const tFiles = useTranslations('files'); + const tDemoWelcome = useTranslations('demo_welcome'); + const tWelcome = useTranslations('welcome'); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); @@ -898,8 +902,9 @@ export function EmailViewer({ // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); - const { identities, client } = useAuthStore(); + const { identities, client, isDemoMode } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); @@ -2684,6 +2689,52 @@ export function EmailViewer({ } if (!email) { + if (isDemoMode) { + const logoSrc = resolvedTheme === 'dark' + ? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg' + : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'; + return ( +
+
+ Bulwark Mail +

{tDemoWelcome('title')}

+

{tDemoWelcome('description')}

+
+
+
+ + {tDemoWelcome('feature_email')} +
+
+ + {tDemoWelcome('feature_organize')} +
+
+ + {tDemoWelcome('feature_shortcuts')} +
+
+ + {tDemoWelcome('feature_privacy')} +
+
+ +

{tDemoWelcome('hint')}

+
+
+
+ ); + } return (
@@ -3233,6 +3284,7 @@ export function EmailViewer({ return (
{/* Mobile More menu sidebar overlay */} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 0a99cb90..1df20c97 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react"; import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts"; import { cn } from "@/lib/utils"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { useTour } from "@/components/tour/tour-provider"; interface KeyboardShortcutsModalProps { isOpen: boolean; @@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps { export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) { const t = useTranslations(); + const { startTour } = useTour(); const modalRef = useFocusTrap({ isActive: isOpen, @@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod

{t("shortcuts.tip")}

+

+ +

diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 4e22eba8..60dbad22 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -303,6 +303,7 @@ export function NavigationRail({ key={item.id} href={item.href} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} + data-tour={`nav-${item.id}`} className={cn( "relative flex items-center gap-2.5 rounded-md transition-colors duration-150", collapsed @@ -401,6 +402,7 @@ export function NavigationRail({ onCloseInlineApp?.() : undefined} + data-tour="nav-settings" className={cn( "flex items-center justify-center w-10 h-10 rounded-md transition-colors", isSettingsActive @@ -418,6 +420,7 @@ export function NavigationRail({ {onShowShortcuts && ( + +
+
+ ); +} + function VacationBanner() { const t = useTranslations('sidebar'); const router = useRouter(); @@ -491,11 +558,14 @@ export function Sidebar({ )}
+ {/* Demo Banner */} + {!isCollapsed && } + {/* Vacation Banner */} {!isCollapsed && } {/* Mailbox List */} -
+
{mailboxes.length === 0 ? (
@@ -582,7 +652,7 @@ export function Sidebar({
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( -
+
{emailKeywords.map((kw) => { const isSelected = selectedKeyword === kw.id; return ( @@ -606,11 +676,11 @@ export function Sidebar({ {/* Compose Button */}
{isCollapsed ? ( - ) : ( - diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index e880e593..1083e0cb 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils'; export function AccountSettings() { const t = useTranslations('settings.account'); - const { username, serverUrl } = useAuthStore(); + const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore(); const { quota } = useEmailStore(); const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; + const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined); return ( + {/* Display Name (show in demo mode or when identity has a name) */} + {displayName && ( + + {displayName} + + )} + {/* Email Address */} {username || t('../../common.unknown')} @@ -49,6 +57,16 @@ export function AccountSettings() {
)} + + {/* Demo mode indicator */} + {isDemoMode && ( + + + + {t('demo_account')} + + + )} ); } diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 81fae268..f046d6c9 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s import { LanguageSwitcher } from '@/components/ui/language-switcher'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { cn } from '@/lib/utils'; +import { useTour } from '@/components/tour/tour-provider'; +import { Button } from '@/components/ui/button'; +import { PlayCircle } from 'lucide-react'; const DENSITY_PREVIEW: Record = { 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, @@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) { export function AppearanceSettings() { const t = useTranslations('settings.appearance'); + const tTour = useTranslations('tour'); const { theme, setTheme } = useThemeStore(); const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore(); + const { startTour, resetTourCompletion } = useTour(); return ( @@ -141,6 +146,19 @@ export function AppearanceSettings() { onChange={(checked) => updateSetting('animationsEnabled', checked)} /> + + {/* Restart Tour */} + + + ); } diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx new file mode 100644 index 00000000..e3d5fff2 --- /dev/null +++ b/components/tour/tour-overlay.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { useTranslations } from "next-intl"; +import { useTour } from "./tour-provider"; +import { cn } from "@/lib/utils"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; + +interface Rect { + top: number; + left: number; + width: number; + height: number; +} + +const PADDING = 8; +const TOOLTIP_GAP = 12; +const TOOLTIP_MAX_W = 360; + +function getTargetRect(selector: string): Rect | null { + const el = document.querySelector(selector); + if (!el) return null; + const r = el.getBoundingClientRect(); + // Element might exist but be hidden (zero dimensions) + if (r.width === 0 && r.height === 0) return null; + return { top: r.top, left: r.left, width: r.width, height: r.height }; +} + +function computeTooltipPosition( + target: Rect, + placement: "top" | "bottom" | "left" | "right", + tooltipSize: { width: number; height: number } +): { top: number; left: number; actualPlacement: string } { + const vw = window.innerWidth; + const vh = window.innerHeight; + const tw = Math.max(tooltipSize.width, 200); // minimum fallback width + const th = Math.max(tooltipSize.height, 100); // minimum fallback height + + const positions = { + bottom: { + top: target.top + target.height + PADDING + TOOLTIP_GAP, + left: target.left + target.width / 2 - tw / 2, + }, + top: { + top: target.top - PADDING - TOOLTIP_GAP - th, + left: target.left + target.width / 2 - tw / 2, + }, + right: { + top: target.top + target.height / 2 - th / 2, + left: target.left + target.width + PADDING + TOOLTIP_GAP, + }, + left: { + top: target.top + target.height / 2 - th / 2, + left: target.left - PADDING - TOOLTIP_GAP - tw, + }, + }; + + const fits = (p: { top: number; left: number }) => + p.top >= 8 && p.left >= 8 && p.top + th <= vh - 8 && p.left + tw <= vw - 8; + + // Try preferred placement first, then fallback order + const order: Array<"top" | "bottom" | "left" | "right"> = [placement, "bottom", "right", "left", "top"]; + for (const dir of order) { + const pos = positions[dir]; + if (fits(pos)) return { ...pos, actualPlacement: dir }; + } + + // If nothing fits perfectly, use preferred but clamped + const pos = positions[placement]; + return { + top: Math.max(8, Math.min(pos.top, vh - th - 8)), + left: Math.max(8, Math.min(pos.left, vw - tw - 8)), + actualPlacement: placement, + }; +} + +export function TourOverlay() { + const t = useTranslations(); + const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour(); + const step = steps[currentStep]; + + const [targetRect, setTargetRect] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null); + const [visible, setVisible] = useState(false); + const [mounted, setMounted] = useState(false); + const tooltipRef = useRef(null); + const pendingTimerRef = useRef | null>(null); + + // Use refs for callbacks to avoid stale closures in timers/intervals + const updatePositionRef = useRef<() => void>(() => {}); + const nextStepRef = useRef<() => void>(() => {}); + nextStepRef.current = nextStep; + + const focusTrapRef = useFocusTrap({ + isActive: visible, + onEscape: stopTour, + restoreFocus: true, + }); + + // Set mounted for portal + useEffect(() => { setMounted(true); }, []); + + const updatePosition = useCallback(() => { + if (!step) return; + const rect = getTargetRect(step.target); + + if (rect) { + setTargetRect(rect); + if (tooltipRef.current) { + const { width, height } = tooltipRef.current.getBoundingClientRect(); + const pos = computeTooltipPosition(rect, step.placement, { width, height }); + setTooltipPos({ top: pos.top, left: pos.left }); + } + } + // If rect is null, keep previous targetRect (element temporarily hidden during scroll/resize) + // Only the step-change effect should null out targetRect + }, [step]); + + // Keep ref in sync + updatePositionRef.current = updatePosition; + + // Wait for target element to appear, then show + useEffect(() => { + if (!step) return; + console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`); + setVisible(false); + // Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position + // instead of disappearing and reappearing + + // Clear any pending timer from a previous step + if (pendingTimerRef.current) { + clearTimeout(pendingTimerRef.current); + pendingTimerRef.current = null; + } + + // Run beforeAction if defined (e.g. click an email to open the viewer) + if (step.beforeAction) { + step.beforeAction(); + } + + let attempts = 0; + const maxAttempts = 50; // 5 seconds + let cancelled = false; + + const tryFind = () => { + if (cancelled) return true; + const el = document.querySelector(step.target); + if (el) { + const rect = el.getBoundingClientRect(); + console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`); + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + // Delay after scroll for layout to settle + pendingTimerRef.current = setTimeout(() => { + if (cancelled) return; + console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`); + updatePositionRef.current(); + setVisible(true); + // Second position update after tooltip renders with final dimensions + requestAnimationFrame(() => { + if (!cancelled) updatePositionRef.current(); + }); + }, 200); + return true; + } + if (attempts % 10 === 0) { + console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`); + } + return false; + }; + + if (tryFind()) return () => { cancelled = true; }; + + // Poll for element appearance (for page navigation) + const interval = setInterval(() => { + attempts++; + if (tryFind() || attempts >= maxAttempts) { + clearInterval(interval); + if (attempts >= maxAttempts && !cancelled) { + // Skip this step if element never appears + console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`); + nextStepRef.current(); + } + } + }, 100); + + return () => { + cancelled = true; + clearInterval(interval); + if (pendingTimerRef.current) { + clearTimeout(pendingTimerRef.current); + pendingTimerRef.current = null; + } + }; + }, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps + + // Recalculate on resize/scroll (debounced) + useEffect(() => { + if (!visible) return; + let rafId: number | null = null; + const handler = () => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + updatePosition(); + }); + }; + window.addEventListener("resize", handler); + window.addEventListener("scroll", handler, true); + return () => { + window.removeEventListener("resize", handler); + window.removeEventListener("scroll", handler, true); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [visible, updatePosition]); + + // Keyboard navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "ArrowRight" || e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + nextStep(); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + e.stopPropagation(); + prevStep(); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + stopTour(); + } + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [nextStep, prevStep, stopTour]); + + // Re-position after tooltip content renders with new dimensions + useEffect(() => { + if (!visible || !tooltipRef.current) return; + // Use rAF to wait for the browser to lay out the tooltip content + const id = requestAnimationFrame(() => { + updatePosition(); + }); + return () => cancelAnimationFrame(id); + }, [visible, updatePosition, currentStep]); + + if (!mounted || !step) return null; + + const cutout = targetRect + ? { + x: targetRect.left - PADDING, + y: targetRect.top - PADDING, + w: targetRect.width + PADDING * 2, + h: targetRect.height + PADDING * 2, + } + : null; + + const isLast = currentStep >= totalSteps - 1; + const isFirst = currentStep === 0; + const isInteractive = step.interactive; + + const reducedMotion = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const transitionStyle = reducedMotion ? "none" : "all 300ms ease"; + + return createPortal( + <> + {/* SVG overlay with cutout */} + + + + + {cutout && ( + + )} + + + + + + {/* Click-through cutout zone for interactive steps */} + {isInteractive && cutout && ( +
+ )} + + {/* Non-interactive overlay click blocker around cutout */} + {!isInteractive && cutout && ( +
+ )} + + {/* Tooltip */} +
{ + (tooltipRef as React.MutableRefObject).current = node; + (focusTrapRef as React.MutableRefObject).current = node; + }} + role="dialog" + aria-modal="true" + aria-label={t(step.titleKey)} + className={cn( + "fixed z-[9999] transition-all", + visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2" + )} + style={{ + top: tooltipPos?.top ?? -9999, + left: tooltipPos?.left ?? -9999, + maxWidth: TOOLTIP_MAX_W, + transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease", + pointerEvents: "auto", + }} + onClick={(e) => e.stopPropagation()} + > +
+ {/* Step counter */} +

+ {t("tour.step_counter", { current: currentStep + 1, total: totalSteps })} +

+ + {/* Title */} +

{t(step.titleKey)}

+ + {/* Description */} +

{t(step.descriptionKey)}

+ + {/* Navigation buttons */} +
+ +
+ + +
+
+ + {/* Progress dots */} +
+ {steps.map((_, i) => ( + + ))} +
+
+
+ , + document.body + ); +} diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx new file mode 100644 index 00000000..1030906b --- /dev/null +++ b/components/tour/tour-provider.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"; +import { useRouter } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useWebDAVStore } from "@/stores/webdav-store"; +import { getTourSteps, type TourStep } from "./tour-steps"; +import { TourOverlay } from "./tour-overlay"; + +const TOUR_COMPLETED_KEY = "tour_completed"; +const TOUR_CURRENT_STEP_KEY = "tour_current_step"; + +interface TourContextValue { + isActive: boolean; + currentStep: number; + totalSteps: number; + steps: TourStep[]; + startTour: () => void; + stopTour: () => void; + nextStep: () => void; + prevStep: () => void; + hasCompletedTour: boolean; + resetTourCompletion: () => void; +} + +const TourContext = createContext(null); + +export function useTour() { + const ctx = useContext(TourContext); + if (!ctx) throw new Error("useTour must be used within TourProvider"); + return ctx; +} + +export function TourProvider({ children }: { children: ReactNode }) { + const router = useRouter(); + const { isDemoMode } = useAuthStore(); + const { supportsCalendar } = useCalendarStore(); + const { supportsWebDAV } = useWebDAVStore(); + + const [isActive, setIsActive] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [hasCompletedTour, setHasCompletedTour] = useState(false); + + const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false }); + + useEffect(() => { + try { + setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true"); + } catch { /* */ } + }, []); + + const startTour = useCallback(() => { + let resumeStep = 0; + try { + const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed; + } + } catch { /* */ } + + // If the resume step is beyond the current steps, start from 0 + if (resumeStep >= steps.length) resumeStep = 0; + + setCurrentStep(resumeStep); + setIsActive(true); + }, [steps.length]); + + const stopTour = useCallback(() => { + setIsActive(false); + try { + localStorage.removeItem(TOUR_CURRENT_STEP_KEY); + } catch { /* */ } + }, []); + + const completeTour = useCallback(() => { + setIsActive(false); + setHasCompletedTour(true); + try { + localStorage.setItem(TOUR_COMPLETED_KEY, "true"); + localStorage.removeItem(TOUR_CURRENT_STEP_KEY); + } catch { /* */ } + }, []); + + const nextStep = useCallback(() => { + if (currentStep >= steps.length - 1) { + completeTour(); + return; + } + const next = currentStep + 1; + const nextStepDef = steps[next]; + setCurrentStep(next); + try { + localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next)); + } catch { /* */ } + + // Navigate if the next step requires a different page + if (nextStepDef?.page) { + router.push(nextStepDef.page); + } + }, [currentStep, steps, completeTour, router]); + + const prevStep = useCallback(() => { + if (currentStep <= 0) return; + const prev = currentStep - 1; + const prevStepDef = steps[prev]; + setCurrentStep(prev); + try { + localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev)); + } catch { /* */ } + + if (prevStepDef?.page) { + router.push(prevStepDef.page); + } else if (steps[currentStep]?.page) { + // Going back from a page-specific step to a non-page step => go to mail + router.push("/"); + } + }, [currentStep, steps, router]); + + const resetTourCompletion = useCallback(() => { + setHasCompletedTour(false); + try { + localStorage.removeItem(TOUR_COMPLETED_KEY); + localStorage.removeItem(TOUR_CURRENT_STEP_KEY); + } catch { /* */ } + }, []); + + const value: TourContextValue = { + isActive, + currentStep, + totalSteps: steps.length, + steps, + startTour, + stopTour, + nextStep, + prevStep, + hasCompletedTour, + resetTourCompletion, + }; + + return ( + + {children} + {isActive && } + + ); +} diff --git a/components/tour/tour-steps.ts b/components/tour/tour-steps.ts new file mode 100644 index 00000000..9f3fdde1 --- /dev/null +++ b/components/tour/tour-steps.ts @@ -0,0 +1,221 @@ +export interface TourStep { + id: string; + target: string; + titleKey: string; + descriptionKey: string; + placement: "top" | "bottom" | "left" | "right"; + interactive?: boolean; + spotlight?: "rect" | "circle"; + page?: string; + demoOnly?: boolean; + beforeAction?: () => void; +} + +export const BASE_TOUR_STEPS: TourStep[] = [ + { + id: "sidebar", + target: '[data-tour="sidebar"]', + titleKey: "tour.sidebar_title", + descriptionKey: "tour.sidebar_desc", + placement: "right", + }, + { + id: "compose", + target: '[data-tour="compose-button"]', + titleKey: "tour.compose_title", + descriptionKey: "tour.compose_desc", + placement: "right", + interactive: true, + }, + { + id: "search", + target: '[data-tour="search-input"]', + titleKey: "tour.search_title", + descriptionKey: "tour.search_desc", + placement: "bottom", + interactive: true, + }, + { + id: "email-list", + target: '[data-tour="email-list"]', + titleKey: "tour.email_list_title", + descriptionKey: "tour.email_list_desc", + placement: "right", + }, + { + id: "email-viewer", + target: '[data-tour="email-viewer"]', + titleKey: "tour.email_viewer_title", + descriptionKey: "tour.email_viewer_desc", + placement: "left", + beforeAction: () => { + // Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer + const emailList = document.querySelector('[data-tour="email-list"]'); + if (!emailList) return; + // Try to find the welcome email by subject text + const items = emailList.querySelectorAll('.cursor-pointer'); + let target: HTMLElement | null = null; + for (const item of items) { + if (item.textContent?.includes("Welcome to Bulwark Mail")) { + target = item as HTMLElement; + break; + } + } + // Fallback to first email if welcome email not found + if (!target) target = emailList.querySelector('.cursor-pointer') as HTMLElement | null; + if (target) target.click(); + }, + }, + { + id: "keywords", + target: '[data-tour="keyword-tags"]', + titleKey: "tour.keywords_title", + descriptionKey: "tour.keywords_desc", + placement: "right", + }, + { + id: "nav-calendar", + target: '[data-tour="nav-calendar"]', + titleKey: "tour.calendar_title", + descriptionKey: "tour.calendar_desc", + placement: "right", + }, + { + id: "nav-contacts", + target: '[data-tour="nav-contacts"]', + titleKey: "tour.contacts_title", + descriptionKey: "tour.contacts_desc", + placement: "right", + }, + { + id: "nav-settings", + target: '[data-tour="nav-settings"]', + titleKey: "tour.settings_title", + descriptionKey: "tour.settings_desc", + placement: "right", + }, + { + id: "shortcuts", + target: '[data-tour="nav-shortcuts"]', + titleKey: "tour.shortcuts_title", + descriptionKey: "tour.shortcuts_desc", + placement: "right", + interactive: true, + }, +]; + +export const DEMO_TOUR_STEPS: TourStep[] = [ + { + id: "compose-open", + target: '[data-tour="composer"]', + titleKey: "tour.compose_open_title", + descriptionKey: "tour.compose_open_desc", + placement: "left", + demoOnly: true, + beforeAction: () => { + // Click the compose button to open the composer + const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null; + if (btn) btn.click(); + }, + }, + { + id: "calendar-view", + target: '[data-tour="calendar-view"]', + titleKey: "tour.calendar_view_title", + descriptionKey: "tour.calendar_view_desc", + placement: "bottom", + page: "/calendar", + demoOnly: true, + }, + { + id: "create-event", + target: '[data-tour="create-event-button"]', + titleKey: "tour.create_event_title", + descriptionKey: "tour.create_event_desc", + placement: "bottom", + page: "/calendar", + interactive: true, + demoOnly: true, + }, + { + id: "event-modal", + target: '[data-tour="event-modal"]', + titleKey: "tour.event_modal_title", + descriptionKey: "tour.event_modal_desc", + placement: "left", + page: "/calendar", + interactive: true, + demoOnly: true, + beforeAction: () => { + // Click the create event button to open the modal + const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null; + if (btn) btn.click(); + }, + }, + { + id: "contacts-list", + target: '[data-tour="contacts-list"]', + titleKey: "tour.contacts_list_title", + descriptionKey: "tour.contacts_list_desc", + placement: "right", + page: "/contacts", + demoOnly: true, + }, + { + id: "settings-tabs", + target: '[data-tour="settings-tabs"]', + titleKey: "tour.settings_tabs_title", + descriptionKey: "tour.settings_tabs_desc", + placement: "right", + page: "/settings", + demoOnly: true, + }, + { + id: "nav-files", + target: '[data-tour="nav-files"]', + titleKey: "tour.files_title", + descriptionKey: "tour.files_desc", + placement: "right", + demoOnly: true, + }, + { + id: "demo-banner", + target: '[data-tour="demo-banner"]', + titleKey: "tour.demo_banner_title", + descriptionKey: "tour.demo_banner_desc", + placement: "bottom", + page: "/", + demoOnly: true, + }, + { + id: "quota", + target: '[data-tour="storage-quota"]', + titleKey: "tour.quota_title", + descriptionKey: "tour.quota_desc", + placement: "right", + demoOnly: true, + }, +]; + +export function getTourSteps(options: { + isDemoMode: boolean; + supportsCalendar: boolean; + supportsWebDAV: boolean; +}): TourStep[] { + let steps = [...BASE_TOUR_STEPS]; + + if (!options.supportsCalendar) { + steps = steps.filter((s) => s.id !== "nav-calendar"); + } + + if (options.isDemoMode) { + const demoSteps = DEMO_TOUR_STEPS.filter((s) => { + if (s.id === "nav-files" && !options.supportsWebDAV) return false; + if ((s.id === "calendar-view" || s.id === "create-event" || s.id === "event-modal") && !options.supportsCalendar) return false; + return true; + }); + steps = [...steps, ...demoSteps]; + } + + return steps; +} diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx index 25a498f6..c8f08e85 100644 --- a/components/ui/welcome-banner.tsx +++ b/components/ui/welcome-banner.tsx @@ -2,15 +2,17 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; -import { X, Lightbulb, Settings } from "lucide-react"; +import { X, Lightbulb, Settings, PlayCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useRouter } from "@/i18n/navigation"; +import { useTour } from "@/components/tour/tour-provider"; const ONBOARDING_KEY = "onboarding_completed"; export function WelcomeBanner() { const t = useTranslations("welcome"); const router = useRouter(); + const { startTour } = useTour(); const [visible, setVisible] = useState(false); const [dismissed, setDismissed] = useState(false); @@ -78,6 +80,15 @@ export function WelcomeBanner() {
+ + ); + })} +
+
+ ); +} diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 7aec12fa..2b69a663 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -14,6 +14,7 @@ import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { useUIStore } from "@/stores/ui-store"; import { EmailIdentityBadge } from "./email-identity-badge"; +import { EmailHoverActions } from "./email-hover-actions"; import { getEmailColorTag } from "@/lib/thread-utils"; interface EmailListItemProps { @@ -21,9 +22,15 @@ interface EmailListItemProps { selected?: boolean; onClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; + onToggleStar?: () => void; + onMarkAsRead?: (read: boolean) => void; + onDelete?: () => void; + onArchive?: () => void; + onSetColorTag?: (color: string | null) => void; + onMarkAsSpam?: () => void; } -export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) { +export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) { const t = useTranslations('email_viewer'); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); @@ -74,7 +81,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email {...dragHandlers} {...longPressHandlers} className={cn( - "relative group cursor-pointer select-none transition-all duration-200 border-b border-border", + "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", // Apply color tag as background, with selected and unread states colorTag ? colorTag : ( selected @@ -217,6 +224,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email )}
+ + {/* Hover Quick Actions */} +
); } \ No newline at end of file diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 693b5175..47248399 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -422,6 +422,12 @@ export function EmailList({ onEmailSelect={(email) => onEmailSelect?.(email)} onContextMenu={openContextMenu} onOpenConversation={onOpenConversation} + onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined} + onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} + onDelete={onDelete ? (email) => onDelete(email) : undefined} + onArchive={onArchive ? (email) => onArchive(email) : undefined} + onSetColorTag={onSetColorTag} + onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} />
); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 28ae8262..311c13cf 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; +import { EmailHoverActions } from "./email-hover-actions"; import { useTranslations } from "next-intl"; interface ThreadListItemProps { @@ -25,6 +26,12 @@ interface ThreadListItemProps { onEmailSelect: (email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; onOpenConversation?: (thread: ThreadGroup) => void; + onToggleStar?: (email: Email) => void; + onMarkAsRead?: (email: Email, read: boolean) => void; + onDelete?: (email: Email) => void; + onArchive?: (email: Email) => void; + onSetColorTag?: (emailId: string, color: string | null) => void; + onMarkAsSpam?: (email: Email) => void; } interface SingleEmailItemProps { @@ -34,10 +41,16 @@ interface SingleEmailItemProps { onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; colorTag: string | null; + onToggleStar?: () => void; + onMarkAsRead?: (read: boolean) => void; + onDelete?: () => void; + onArchive?: () => void; + onSetColorTag?: (color: string | null) => void; + onMarkAsSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) { + function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const sender = email.from?.[0]; @@ -100,7 +113,7 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...longPressHandlers} className={cn( - "relative group cursor-pointer select-none transition-all duration-200 border-b border-border", + "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", resolvedColorTag ? resolvedColorTag : ( selected ? "bg-accent" @@ -216,6 +229,17 @@ const SingleEmailItem = React.forwardRef( )}

+ + {/* Hover Quick Actions */} +
); } @@ -232,6 +256,12 @@ export const ThreadListItem = React.forwardRef state.showPreview); @@ -278,6 +308,12 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} + onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} + onDelete={onDelete ? () => onDelete(latestEmail) : undefined} + onArchive={onArchive ? () => onArchive(latestEmail) : undefined} + onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} /> ); } @@ -339,7 +375,7 @@ export const ThreadListItem = React.forwardRef
+ + {/* Hover Quick Actions for thread header */} + onToggleStar(latestEmail) : undefined} + onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} + onDelete={onDelete ? () => onDelete(latestEmail) : undefined} + onArchive={onArchive ? () => onArchive(latestEmail) : undefined} + onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} + />
{isExpanded && !isMobile && ( diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index ed74e35e..6bd31dea 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -3,9 +3,11 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; -import type { ArchiveMode } from '@/stores/settings-store'; +import type { ArchiveMode, HoverAction } from '@/stores/settings-store'; +import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; +import { cn } from '@/lib/utils'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react'; @@ -27,6 +29,7 @@ export function EmailSettings() { attachmentPosition, emailAlwaysLightMode, archiveMode, + hoverActions, trustedSenders, updateSetting, } = useSettingsStore(); @@ -185,6 +188,39 @@ export function EmailSettings() { updateSetting('showPreview', checked)} /> + {/* Quick Hover Actions */} +
+
+ +

{t('hover_actions.description')}

+
+
+ {ALL_HOVER_ACTIONS.map((action) => { + const isEnabled = hoverActions.includes(action.id); + return ( + + ); + })} +
+
+ setKeywordsStr(e.target.value)} - placeholder={t("categories_placeholder")} - /> -

{t("categories_hint")}

-
+ {/* Gender */} @@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact ); } + +function CategoryComboBox({ + keywordsStr, + onChange, + allKeywords, + placeholder, + hint, + addLabel, +}: { + keywordsStr: string; + onChange: (value: string) => void; + allKeywords: string[]; + placeholder: string; + hint: string; + addLabel: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + const wrapperRef = useRef(null); + const inputRef = useRef(null); + + // Parse current keywords from comma-separated string + const currentKeywords = useMemo(() => { + return keywordsStr.split(",").map(k => k.trim()).filter(Boolean); + }, [keywordsStr]); + + // Suggestions: existing keywords not already selected + const suggestions = useMemo(() => { + const lower = inputValue.toLowerCase(); + return allKeywords.filter(kw => + !currentKeywords.includes(kw) && + (!lower || kw.toLowerCase().includes(lower)) + ); + }, [allKeywords, currentKeywords, inputValue]); + + // Can add a new keyword if typed text is non-empty and not already in the list + const canAddNew = inputValue.trim() && + !currentKeywords.includes(inputValue.trim()) && + !allKeywords.some(kw => kw.toLowerCase() === inputValue.trim().toLowerCase()); + + const addKeyword = useCallback((keyword: string) => { + const trimmed = keyword.trim(); + if (!trimmed || currentKeywords.includes(trimmed)) return; + const next = [...currentKeywords, trimmed].join(", "); + onChange(next); + setInputValue(""); + }, [currentKeywords, onChange]); + + const removeKeyword = useCallback((keyword: string) => { + const next = currentKeywords.filter(k => k !== keyword).join(", "); + onChange(next); + }, [currentKeywords, onChange]); + + // Close dropdown on outside click + useEffect(() => { + if (!isOpen) return; + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [isOpen]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (inputValue.trim()) { + addKeyword(inputValue); + } + } else if (e.key === "Escape") { + setIsOpen(false); + } + }; + + return ( +
+ {/* Keyword badges */} + {currentKeywords.length > 0 && ( +
+ {currentKeywords.map(kw => ( + + {kw} + + + ))} +
+ )} + + {/* Input with dropdown */} + { setInputValue(e.target.value); setIsOpen(true); }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={currentKeywords.length === 0 ? placeholder : ""} + /> +

{hint}

+ + {/* Dropdown */} + {isOpen && (suggestions.length > 0 || canAddNew) && ( +
+ {suggestions.map(kw => ( + + ))} + {canAddNew && ( + + )} +
+ )} +
+ ); +} diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index ccb13479..2b78a634 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, ? Array.from(selectedContactIds) : [contact.id]; - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids)); e.dataTransfer.setData("text/plain", name || email || contact.id); diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 7973c2b3..f4f5cd45 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; -export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string }; +export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; interface ContactsSidebarProps { groups: ContactCard[]; @@ -24,6 +24,7 @@ interface ContactsSidebarProps { onEditGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; + onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; className?: string; } @@ -56,6 +57,7 @@ export function ContactsSidebar({ onEditGroup, onDeleteGroup, onDropContacts, + onDropContactsToCategory, className, }: ContactsSidebarProps) { const t = useTranslations("contacts"); @@ -146,6 +148,11 @@ export function ContactsSidebar({ return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); }, [individuals]); + // Count of contacts without any keywords + const uncategorizedCount = useMemo(() => { + return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length; + }, [individuals]); + // Resolve actual group member counts against living contacts const memberCountByGroup = useMemo(() => { const counts: Record = {}; @@ -311,46 +318,56 @@ export function ContactsSidebar({ )} {/* Categories section (from contact keywords) */} - {allKeywords.length > 0 && ( -
- +
+ - {!collapsed.categories && allKeywords.map(([keyword, count]) => { - const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; - return ( - - ); - })} -
- )} + {!collapsed.categories && ( + <> + {/* No Category item */} + + {allKeywords.map(([keyword, count]) => { + const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; + return ( + onSelectCategory({ keyword })} + onDropContacts={onDropContactsToCategory} + /> + ); + })} + + )} +
{/* Shared accounts with address books */} {sharedBookGroups.map((group) => ( @@ -415,6 +432,71 @@ export function ContactsSidebar({ ); } +function CategoryItem({ + keyword, + count, + isActive, + onSelect, + onDropContacts, +}: { + keyword: string; + count: number; + isActive: boolean; + onSelect: () => void; + onDropContacts?: (contactIds: string[], keyword: string) => void; +}) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = useCallback((e: DragEvent) => { + if (!e.dataTransfer.types.includes("application/x-contact-ids")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const data = e.dataTransfer.getData("application/x-contact-ids"); + if (!data || !onDropContacts) return; + try { + const contactIds = JSON.parse(data) as string[]; + if (contactIds.length > 0) { + onDropContacts(contactIds, keyword); + } + } catch { + // ignore invalid data + } + }, [keyword, onDropContacts]); + + return ( + + ); +} + function AddressBookItem({ book, isActive, diff --git a/locales/de/common.json b/locales/de/common.json index df101114..cc3dd9b5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1482,6 +1482,9 @@ "title": "Kontakte", "search_placeholder": "Kontakte suchen...", "create_new": "Neuer Kontakt", + "no_category": "Ohne Kategorie", + "category_added": "Kontakt zu {name} hinzugefügt", + "category_added_plural": "{count} Kontakte zu {name} hinzugefügt", "empty_state": "Keine Kontakte", "empty_state_title": "Keine Kontakte", "empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei", @@ -1625,7 +1628,8 @@ "level_low": "Niedrig", "categories": "Kategorien", "categories_placeholder": "z. B. Familie, Freunde, Kollegen", - "categories_hint": "Mit Kommas trennen", + "categories_hint": "Tippen zum Suchen oder Hinzufügen", + "category_add": "Hinzufügen", "note": "Notizen", "note_placeholder": "Notiz hinzufügen...", "gender": "Geschlecht", diff --git a/locales/en/common.json b/locales/en/common.json index 175923e7..792b17e8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1495,6 +1495,9 @@ "title": "Contacts", "search_placeholder": "Search contacts...", "create_new": "New Contact", + "no_category": "No Category", + "category_added": "Contact added to {name}", + "category_added_plural": "{count} contacts added to {name}", "empty_state": "No contacts yet", "empty_state_title": "No contacts yet", "empty_state_subtitle": "Create your first contact or import from a vCard file", @@ -1638,7 +1641,8 @@ "level_low": "Low", "categories": "Categories", "categories_placeholder": "e.g., Family, Friends, Colleagues", - "categories_hint": "Separate with commas", + "categories_hint": "Type to search or add categories", + "category_add": "Add", "note": "Notes", "note_placeholder": "Add a note...", "gender": "Gender", diff --git a/locales/es/common.json b/locales/es/common.json index 2c88f7f4..3e1ca3a6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1482,6 +1482,9 @@ "title": "Contactos", "search_placeholder": "Buscar contactos...", "create_new": "Nuevo contacto", + "no_category": "Sin categoría", + "category_added": "Contacto añadido a {name}", + "category_added_plural": "{count} contactos añadidos a {name}", "empty_state": "No hay contactos", "empty_state_title": "Sin contactos", "empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard", @@ -1625,7 +1628,8 @@ "level_low": "Bajo", "categories": "Categorías", "categories_placeholder": "p. ej., Familia, Amigos, Colegas", - "categories_hint": "Separar con comas", + "categories_hint": "Escriba para buscar o añadir categorías", + "category_add": "Añadir", "note": "Notas", "note_placeholder": "Agregar una nota...", "gender": "Género", diff --git a/locales/fr/common.json b/locales/fr/common.json index e413d102..fd8fa9c5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1482,6 +1482,9 @@ "title": "Contacts", "search_placeholder": "Rechercher des contacts...", "create_new": "Nouveau contact", + "no_category": "Sans catégorie", + "category_added": "Contact ajouté à {name}", + "category_added_plural": "{count} contacts ajoutés à {name}", "empty_state": "Aucun contact", "empty_state_title": "Aucun contact", "empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard", @@ -1625,7 +1628,8 @@ "level_low": "Faible", "categories": "Catégories", "categories_placeholder": "p. ex., Famille, Amis, Collègues", - "categories_hint": "Séparer par des virgules", + "categories_hint": "Tapez pour rechercher ou ajouter", + "category_add": "Ajouter", "note": "Notes", "note_placeholder": "Ajouter une note...", "gender": "Genre", diff --git a/locales/it/common.json b/locales/it/common.json index 3896f75e..7186eeb8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1482,6 +1482,9 @@ "title": "Contatti", "search_placeholder": "Cerca contatti...", "create_new": "Nuovo contatto", + "no_category": "Senza categoria", + "category_added": "Contatto aggiunto a {name}", + "category_added_plural": "{count} contatti aggiunti a {name}", "empty_state": "Nessun contatto", "empty_state_title": "Nessun contatto", "empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard", @@ -1625,7 +1628,8 @@ "level_low": "Basso", "categories": "Categorie", "categories_placeholder": "es., Famiglia, Amici, Colleghi", - "categories_hint": "Separare con virgole", + "categories_hint": "Digita per cercare o aggiungere", + "category_add": "Aggiungi", "note": "Note", "note_placeholder": "Aggiungi una nota...", "gender": "Genere", diff --git a/locales/ja/common.json b/locales/ja/common.json index 93795999..d35aee06 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1482,6 +1482,9 @@ "title": "連絡先", "search_placeholder": "連絡先を検索...", "create_new": "新しい連絡先", + "no_category": "カテゴリなし", + "category_added": "{name} に連絡先を追加しました", + "category_added_plural": "{count} 件の連絡先を {name} に追加しました", "empty_state": "連絡先がありません", "empty_state_title": "連絡先がありません", "empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください", @@ -1625,7 +1628,8 @@ "level_low": "低", "categories": "カテゴリー", "categories_placeholder": "例:家族、友人、同僚", - "categories_hint": "カンマで区切ってください", + "categories_hint": "検索または追加するには入力", + "category_add": "追加", "note": "メモ", "note_placeholder": "メモを追加...", "gender": "性別", diff --git a/locales/nl/common.json b/locales/nl/common.json index ce46acf2..2125b3c9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1482,6 +1482,9 @@ "title": "Contacten", "search_placeholder": "Contacten zoeken...", "create_new": "Nieuw contact", + "no_category": "Geen categorie", + "category_added": "Contact toegevoegd aan {name}", + "category_added_plural": "{count} contacten toegevoegd aan {name}", "empty_state": "Geen contacten", "empty_state_title": "Geen contacten", "empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand", @@ -1625,7 +1628,8 @@ "level_low": "Laag", "categories": "Categorieën", "categories_placeholder": "bijv. Familie, Vrienden, Collega's", - "categories_hint": "Scheiden met komma's", + "categories_hint": "Typ om te zoeken of toe te voegen", + "category_add": "Toevoegen", "note": "Notities", "note_placeholder": "Notitie toevoegen...", "gender": "Geslacht", diff --git a/locales/pt/common.json b/locales/pt/common.json index 07bd4897..8dee9921 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1482,6 +1482,9 @@ "title": "Contatos", "search_placeholder": "Pesquisar contatos...", "create_new": "Novo contato", + "no_category": "Sem categoria", + "category_added": "Contato adicionado a {name}", + "category_added_plural": "{count} contatos adicionados a {name}", "empty_state": "Nenhum contato", "empty_state_title": "Sem contatos", "empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard", @@ -1625,7 +1628,8 @@ "level_low": "Baixo", "categories": "Categorias", "categories_placeholder": "ex., Família, Amigos, Colegas", - "categories_hint": "Separar com vírgulas", + "categories_hint": "Digite para pesquisar ou adicionar", + "category_add": "Adicionar", "note": "Notas", "note_placeholder": "Adicionar uma nota...", "gender": "Gênero", From 6ed8ae5812b035043dcfcf4a77f25342a9cc1295 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:08:19 +0100 Subject: [PATCH 7/7] chore: bump version to 1.4.6 --- CHANGELOG.md | 17 +++++++++++++++++ VERSION | 2 +- package.json | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa70b9dc..346cda47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.4.6 (2026-03-21) + +### Features + +- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses +- **Demo**: Implement JMAP client interface abstraction to support demo and live backends +- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form +- **Email**: Add hover actions for emails with configurable quick-action buttons +- **Settings**: Implement keyword migration functionality for upgrading legacy email tags +- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support +- **Tour**: Add interactive guided tour overlay for new user onboarding + +### Fixes + +- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store +- **UI**: Adjust padding and size of sidebar buttons for improved layout + ## 1.4.5 (2026-03-20) ### Features diff --git a/VERSION b/VERSION index e516bb9d..c514bd85 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.5 +1.4.6 diff --git a/package.json b/package.json index 07bbd6b8..fd3942f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.5", + "version": "1.4.6", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only",