diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index f910dc42..7f58d935 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -161,6 +161,7 @@ const tabSearchPaths: Record = { 'settings.account.email', 'settings.account.server', 'settings.account.storage', + 'settings.account.accounts', ], language: ['settings.appearance.language'], notifications: ['settings.notifications'], @@ -228,7 +229,7 @@ const tabSearchPaths: Record = { // Extra English keywords per tab so common search terms hit even when the // translation doesn't contain the literal word. const tabKeywords: Record = { - account: 'profile email password user signin signout', + account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account', language: 'locale region timezone date time format', notifications: 'sound alert push badge', appearance: 'theme dark light font size accent color animation density', diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 3e70b9cd..a332d463 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -1,87 +1,361 @@ "use client"; +import { useState, useRef, useCallback } from 'react'; import { useTranslations } from 'next-intl'; +import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; -import { useAccountStore } from '@/stores/account-store'; +import { useAccountStore, type AccountEntry } from '@/stores/account-store'; import { SettingsSection, SettingItem } from './settings-section'; -import { formatFileSize } from '@/lib/utils'; +import { Avatar } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { useRouter } from '@/i18n/navigation'; +import { getMaxAccounts } from '@/lib/account-utils'; +import { formatFileSize, cn } from '@/lib/utils'; + +function hostnameOf(serverUrl: string): string { + try { return new URL(serverUrl).hostname; } catch { return serverUrl; } +} export function AccountSettings() { const t = useTranslations('settings.account'); - const { username, serverUrl, isDemoMode, primaryIdentity, authMode, activeAccountId } = useAuthStore(); + const router = useRouter(); + const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore(); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const switchAccount = useAuthStore((s) => s.switchAccount); const { quota } = useEmailStore(); + const accounts = useAccountStore((s) => s.accounts); + const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); + const reorderAccounts = useAccountStore((s) => s.reorderAccounts); const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined); + const [dragOverIndex, setDragOverIndex] = useState(null); + const draggedIndexRef = useRef(null); + const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined); const email = primaryIdentity?.email || account?.email || username; + const max = getMaxAccounts(); + + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + draggedIndexRef.current = index; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(index)); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIndex(index); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + setDragOverIndex(null); + const fromIndex = draggedIndexRef.current; + if (fromIndex === null || fromIndex === dropIndex) return; + const next = accounts.map((a) => a.id); + const [moved] = next.splice(fromIndex, 1); + next.splice(dropIndex, 0, moved); + reorderAccounts(next); + }, [accounts, reorderAccounts]); + + const handleDragEnd = useCallback(() => { + draggedIndexRef.current = null; + setDragOverIndex(null); + }, []); + + const moveAccount = useCallback((from: number, to: number) => { + if (to < 0 || to >= accounts.length || from === to) return; + const next = accounts.map((a) => a.id); + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + reorderAccounts(next); + }, [accounts, reorderAccounts]); + + const handleSwitch = useCallback((id: string) => { + if (id === activeAccountId) return; + void switchAccount(id); + }, [activeAccountId, switchAccount]); + + const handleAddAccount = useCallback(() => { + router.push(`/login?mode=add-account` as never); + }, [router]); return ( - - {/* Display Name */} - - {displayName || t('../../common.unknown')} - - - {/* Email Address */} - - {email || t('../../common.unknown')} - - - {/* Username / Login (show when it differs from email) */} - {username && username !== email && ( - - {username} +
+ + {/* Display Name */} + + {displayName || t('../../common.unknown')} - )} - {/* Authentication Method */} - - - {authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')} - - - - {/* Server */} - - - {serverUrl || t('../../common.unknown')} - - - - {/* Storage */} - {quota && quota.total > 0 && ( - -
- - {t('storage.percentage', { percent: quotaPercentage })} - -
-
-
-
+ {/* Email Address */} + + {email || t('../../common.unknown')} - )} - {/* Demo mode indicator */} - {isDemoMode && ( - - - - {t('demo_account')} + {/* Username / Login (show when it differs from email) */} + {username && username !== email && ( + + {username} + + )} + + {/* Authentication Method */} + + + {authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')} + + {/* Server */} + + + {serverUrl || t('../../common.unknown')} + + + + {/* Storage */} + {quota && quota.total > 0 && ( + +
+ + {t('storage.percentage', { percent: quotaPercentage })} + +
+
+
+
+ + )} + + {/* Demo mode indicator */} + {isDemoMode && ( + + + + {t('demo_account')} + + + )} + + + {/* Logged-in accounts list */} + {accounts.length > 0 && ( + +
+ {accounts.map((a, index) => ( + moveAccount(index, index - 1)} + onMoveDown={() => moveAccount(index, index + 1)} + onSwitch={() => handleSwitch(a.id)} + onSetDefault={() => setDefaultAccount(a.id)} + labels={{ + active: t('accounts.active'), + default: t('accounts.default_badge'), + setDefault: t('accounts.set_default'), + switchTo: t('accounts.switch_to'), + moveUp: t('accounts.move_up'), + moveDown: t('accounts.move_down'), + dragHandle: t('accounts.drag_handle'), + }} + /> + ))} + + {accounts.length < max && ( + + )} +
+
)} - +
+ ); +} + +interface AccountRowProps { + account: AccountEntry; + index: number; + isActive: boolean; + isFirst: boolean; + isLast: boolean; + isDragOver: boolean; + onDragStart: (e: React.DragEvent, index: number) => void; + onDragOver: (e: React.DragEvent, index: number) => void; + onDrop: (e: React.DragEvent, index: number) => void; + onDragEnd: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onSwitch: () => void; + onSetDefault: () => void; + labels: { + active: string; + default: string; + setDefault: string; + switchTo: string; + moveUp: string; + moveDown: string; + dragHandle: string; + }; +} + +function AccountRow({ + account, + index, + isActive, + isFirst, + isLast, + isDragOver, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + onMoveUp, + onMoveDown, + onSwitch, + onSetDefault, + labels, +}: AccountRowProps) { + return ( +
onDragStart(e, index)} + onDragOver={(e) => onDragOver(e, index)} + onDrop={(e) => onDrop(e, index)} + onDragEnd={onDragEnd} + className={cn( + 'flex items-center gap-3 p-3 border rounded-lg transition-colors', + isDragOver + ? 'border-primary bg-primary/5' + : isActive + ? 'border-border bg-accent/30' + : 'border-border hover:bg-muted/50' + )} + > +
+ +
+ +
+ + {isActive && ( +
+ +
+ )} +
+ + + +
+ {!account.isDefault && ( + + )} + + +
+
); } diff --git a/locales/de/common.json b/locales/de/common.json index 8a0fb9c7..4b0c168d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Letzte Synchronisierung", "value": "{time}" + }, + "accounts": { + "title": "Angemeldete Konten", + "description": "Ziehen Sie zum Sortieren, wie Konten im Kontomenü erscheinen", + "active": "Aktuell aktives Konto", + "default_badge": "Standardkonto", + "set_default": "Als Standard festlegen", + "switch_to": "Zu diesem Konto wechseln", + "move_up": "Nach oben", + "move_down": "Nach unten", + "drag_handle": "Zum Sortieren ziehen", + "add": "Konto hinzufügen" } }, "security": { diff --git a/locales/en/common.json b/locales/en/common.json index 9b33aac4..45c0232b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1202,6 +1202,18 @@ "last_sync": { "label": "Last Sync", "value": "{time}" + }, + "accounts": { + "title": "Logged-in accounts", + "description": "Drag to reorder how accounts appear in the account dropdown", + "active": "Currently active account", + "default_badge": "Default account", + "set_default": "Set as default", + "switch_to": "Switch to this account", + "move_up": "Move up", + "move_down": "Move down", + "drag_handle": "Drag to reorder", + "add": "Add account" } }, "security": { diff --git a/stores/account-store.ts b/stores/account-store.ts index 93f452ac..eb33ebe3 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -43,6 +43,7 @@ interface AccountState { setDefaultAccount: (accountId: string) => void; getDefaultAccount: () => AccountEntry | null; updateAccount: (accountId: string, updates: Partial) => void; + reorderAccounts: (orderedIds: string[]) => void; getActiveAccount: () => AccountEntry | null; getAccountById: (accountId: string) => AccountEntry | undefined; getNextCookieSlot: () => number; @@ -168,6 +169,23 @@ export const useAccountStore = create()( })); }, + reorderAccounts: (orderedIds) => { + set((s) => { + const byId = new Map(s.accounts.map((a) => [a.id, a])); + const reordered: AccountEntry[] = []; + for (const id of orderedIds) { + const a = byId.get(id); + if (a) { + reordered.push(a); + byId.delete(id); + } + } + // Append any accounts that weren't in the ordered list (defensive) + for (const a of byId.values()) reordered.push(a); + return { accounts: reordered }; + }); + }, + getActiveAccount: () => { const state = get(); return state.accounts.find((a) => a.id === state.activeAccountId) ?? null;