diff --git a/.github/workflows/docker-publish-releases.yml b/.github/workflows/docker-publish-releases.yml deleted file mode 100644 index 1dd127d8..00000000 --- a/.github/workflows/docker-publish-releases.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Publish Docker Image on Release - -on: - release: - types: [published] - -env: - IMAGE_NAME: ghcr.io/${{ github.repository }} - -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - - platform: linux/arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - - - name: Build and push by digest - id: build - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha,scope=${{ matrix.platform }} - cache-to: type=gha,mode=max,scope=${{ matrix.platform }} - - - name: Export digest - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v4 - with: - name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - runs-on: ubuntu-latest - needs: build - permissions: - contents: read - packages: write - - steps: - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - tags: | - type=raw,value=latest - type=semver,pattern=v{{version}} - type=semver,pattern={{version}} - type=semver,pattern=v{{major}}.{{minor}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern=v{{major}} - type=semver,pattern={{major}} - - - name: Create manifest list and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) - - - name: Inspect image - run: | - docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3e17dbed..11a9654d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,9 +2,7 @@ name: Publish Docker Image on: push: - branches: - - main - - dev + branches: [main] paths: - "Dockerfile" - ".dockerignore" @@ -19,6 +17,7 @@ on: - "package.json" - "package-lock.json" - ".github/workflows/docker-publish.yml" + tags: ["v*.*.*"] workflow_dispatch: env: @@ -115,8 +114,10 @@ jobs: with: images: ${{ env.IMAGE_NAME }} tags: | - type=raw,value={{branch}} - type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix= - name: Create manifest list and push working-directory: /tmp/digests 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/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..1b8bc214 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -126,9 +126,24 @@ export default function ContactsPage() { const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; + // Collect all unique keywords across contacts + const allKeywords = useMemo(() => { + const kws = new Set(); + for (const contact of individuals) { + if (!contact.keywords) continue; + for (const [kw, active] of Object.entries(contact.keywords)) { + if (active) kws.add(kw); + } + } + return Array.from(kws).sort((a, b) => a.localeCompare(b)); + }, [individuals]); + // Contacts to display based on active category const displayedContacts = useMemo(() => { if (activeCategory === "all") return individuals; + if (activeCategory === "uncategorized") { + return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0); + } if ("addressBookId" in activeCategory) { const bookId = activeCategory.addressBookId; return individuals.filter(c => { @@ -146,6 +161,7 @@ export default function ContactsPage() { // Label for the current category const categoryLabel = useMemo(() => { if (activeCategory === "all") return t("tabs.all"); + if (activeCategory === "uncategorized") return t("no_category"); if ("addressBookId" in activeCategory) { const book = addressBooks.find(b => b.id === activeCategory.addressBookId); return book?.name || t("tabs.all"); @@ -182,6 +198,31 @@ export default function ContactsPage() { } }, [client, moveContactToAddressBook, t]); + const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => { + if (!client && supportsSync) return; + try { + for (const contactId of contactIds) { + const contact = contacts.find(c => c.id === contactId); + if (!contact) continue; + const existingKeywords = contact.keywords || {}; + if (existingKeywords[keyword]) continue; // already has this keyword + const updatedKeywords = { ...existingKeywords, [keyword]: true }; + if (supportsSync && client) { + await updateContact(client, contactId, { keywords: updatedKeywords }); + } else { + updateLocalContact(contactId, { keywords: updatedKeywords }); + } + } + const msg = contactIds.length === 1 + ? t("category_added", { name: keyword }) + : t("category_added_plural", { count: contactIds.length, name: keyword }); + toast.success(msg); + } catch (error) { + console.error('Failed to add contacts to category:', error); + toast.error(t("toast.error_update")); + } + }, [client, supportsSync, contacts, updateContact, updateLocalContact, t]); + const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { return importContacts( supportsSync && client ? client : null, @@ -430,7 +471,7 @@ export default function ContactsPage() { const renderRightPanel = () => { switch (view) { case "create": - return ; + return ; case "edit": if (!selectedContact) return null; @@ -438,6 +479,7 @@ export default function ContactsPage() { @@ -590,6 +632,7 @@ export default function ContactsPage() { onEditGroup={handleEditGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar} onDropContacts={handleDropContacts} + onDropContactsToCategory={handleDropContactsToCategory} />
- {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 && ( 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/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index fbe1f59f..dee16b0f 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -48,6 +48,7 @@ interface AddressEntry { interface ContactFormProps { contact?: ContactCard | null; addressBooks?: AddressBook[]; + allKeywords?: string[]; onSave: (data: Partial) => Promise; onCancel: () => void; } @@ -123,7 +124,7 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; @@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact {/* Categories */} -
- 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/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 (
void; + onMarkAsRead?: (read: boolean) => void; + onDelete?: () => void; + onArchive?: () => void; + onSetColorTag?: (color: string | null) => void; + onMarkAsSpam?: () => void; +} + +const ACTION_CONFIG: Record = { + delete: { + icon: Trash2, + titleKey: "delete", + className: "hover:text-red-600 dark:hover:text-red-400", + }, + star: { + icon: Star, + titleKey: "star", + className: "hover:text-amber-500 dark:hover:text-amber-400", + }, + markRead: { + icon: Mail, + titleKey: "mark_read", + className: "hover:text-blue-600 dark:hover:text-blue-400", + }, + archive: { + icon: Archive, + titleKey: "archive", + className: "hover:text-green-600 dark:hover:text-green-400", + }, + tag: { + icon: Tag, + titleKey: "tag", + className: "hover:text-purple-600 dark:hover:text-purple-400", + }, + spam: { + icon: ShieldAlert, + titleKey: "spam", + className: "hover:text-orange-600 dark:hover:text-orange-400", + }, +}; + +export function EmailHoverActions({ + email, + onToggleStar, + onMarkAsRead, + onDelete, + onArchive, + onSetColorTag, + onMarkAsSpam, +}: EmailHoverActionsProps) { + const hoverActions = useSettingsStore((state) => state.hoverActions); + const t = useTranslations("settings.email_behavior.hover_actions"); + + const isUnread = !email.keywords?.$seen; + const isStarred = email.keywords?.$flagged; + + if (hoverActions.length === 0) return null; + + const handleAction = (e: React.MouseEvent, action: HoverAction) => { + e.stopPropagation(); + e.preventDefault(); + switch (action) { + case "delete": + onDelete?.(); + break; + case "star": + onToggleStar?.(); + break; + case "markRead": + onMarkAsRead?.(!isUnread); + break; + case "archive": + onArchive?.(); + break; + case "tag": + onSetColorTag?.(null); + break; + case "spam": + onMarkAsSpam?.(); + break; + } + }; + + return ( +
+
+
+ {hoverActions.map((actionId) => { + const config = ACTION_CONFIG[actionId]; + if (!config) return null; + const Icon = config.icon; + + const DisplayIcon = actionId === "markRead" + ? (isUnread ? MailOpen : Mail) + : actionId === "star" && isStarred + ? Star + : Icon; + + return ( + + ); + })} +
+
+ ); +} 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 095913f2..47248399 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -358,7 +358,7 @@ export function EmailList({ )} {/* Email List */} -
+
{/* Loading overlay */} {isLoading && emails.length > 0 && (
@@ -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/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/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/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(); @@ -454,12 +521,12 @@ export function Sidebar({ )} > {/* Header */} -
+
+ {/* 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/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 ( + + ); + })} +
+
+