feat: list and reorder logged-in accounts in settings #282

This commit is contained in:
Linus Rath
2026-05-20 19:22:44 +02:00
parent ba90ec1f7a
commit d45c8ef511
5 changed files with 379 additions and 62 deletions
+2 -1
View File
@@ -161,6 +161,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'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<Tab, string[]> = {
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
const tabKeywords: Record<Tab, string> = {
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',
+335 -61
View File
@@ -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<number | null>(null);
const draggedIndexRef = useRef<number | null>(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 (
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || t('../../common.unknown')}</span>
</SettingItem>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{email || t('../../common.unknown')}</span>
</SettingItem>
{/* Username / Login (show when it differs from email) */}
{username && username !== email && (
<SettingItem label={t('username_label')}>
<span className="text-sm text-foreground">{username}</span>
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || t('../../common.unknown')}</span>
</SettingItem>
)}
{/* Authentication Method */}
<SettingItem label={t('auth_method_label')}>
<span className="text-sm text-foreground">
{authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{email || t('../../common.unknown')}</span>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
{/* Username / Login (show when it differs from email) */}
{username && username !== email && (
<SettingItem label={t('username_label')}>
<span className="text-sm text-foreground">{username}</span>
</SettingItem>
)}
{/* Authentication Method */}
<SettingItem label={t('auth_method_label')}>
<span className="text-sm text-foreground">
{authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
</span>
</SettingItem>
)}
</SettingsSection>
{/* Logged-in accounts list */}
{accounts.length > 0 && (
<SettingsSection title={t('accounts.title')} description={t('accounts.description')}>
<div className="space-y-2">
{accounts.map((a, index) => (
<AccountRow
key={a.id}
account={a}
index={index}
isActive={a.id === activeAccountId}
isFirst={index === 0}
isLast={index === accounts.length - 1}
isDragOver={dragOverIndex === index}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onMoveUp={() => 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 && (
<Button
variant="outline"
size="sm"
onClick={handleAddAccount}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
{t('accounts.add')}
</Button>
)}
</div>
</SettingsSection>
)}
</SettingsSection>
</div>
);
}
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 (
<div
draggable
onDragStart={(e) => 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'
)}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
title={labels.dragHandle}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="relative flex-shrink-0">
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className="w-9 h-9 text-sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-primary-foreground" />
</div>
)}
</div>
<button
type="button"
onClick={onSwitch}
disabled={isActive}
className={cn(
'min-w-0 flex-1 text-left',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{account.displayName || account.label}
</span>
{account.isDefault && (
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" aria-label={labels.default} />
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{account.email || account.username}
</p>
<div className="flex items-center gap-1 mt-0.5">
{account.hasError ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
account.isConnected ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
)}
<span className="text-[10px] text-muted-foreground truncate">
{hostnameOf(account.serverUrl)}
</span>
</div>
</button>
<div className="flex items-center gap-0.5 flex-shrink-0">
{!account.isDefault && (
<button
type="button"
onClick={onSetDefault}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-amber-500 transition-colors"
title={labels.setDefault}
aria-label={labels.setDefault}
>
<Star className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveUp}
aria-label={labels.moveUp}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 10l4-4 4 4" />
</svg>
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveDown}
aria-label={labels.moveDown}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</div>
</div>
);
}
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+18
View File
@@ -43,6 +43,7 @@ interface AccountState {
setDefaultAccount: (accountId: string) => void;
getDefaultAccount: () => AccountEntry | null;
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => void;
reorderAccounts: (orderedIds: string[]) => void;
getActiveAccount: () => AccountEntry | null;
getAccountById: (accountId: string) => AccountEntry | undefined;
getNextCookieSlot: () => number;
@@ -168,6 +169,23 @@ export const useAccountStore = create<AccountState>()(
}));
},
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;