feat: list and reorder logged-in accounts in settings #282
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user