refactor(ui): update settings pages for new theme system

Update all settings pages (AccessKeys, Appearance, Billing, Email,
MyAccount, Notifications, Password, PushSettings, SettingsLayout,
SshKeys) to use CSS variable-based theme tokens.
This commit is contained in:
ZhenYi 2026-05-18 20:44:37 +08:00
parent 16739d3cf8
commit 86ab2d2f85
10 changed files with 2280 additions and 1671 deletions

View File

@ -1,187 +1,329 @@
import { useState } from "react"; import { useState } from "react"
import type { AccessKeyResponse } from "@/client/model"; import { format } from "date-fns"
import { Button } from "@/components/ui/button"; import {
import { Input } from "@/components/ui/input"; AlertCircle,
import { Label } from "@/components/ui/label"; Check,
import { Plus, Trash2, Key, Copy, Check } from "lucide-react"; Copy,
import { format } from "date-fns"; Key,
import { useAccessKeysQuery, useCreateAccessKeyMutation, useDeleteAccessKeyMutation } from "@/hooks/useAccessKeysQuery"; Loader2,
import { t } from "@/i18n/T"; Plus,
Trash2,
} from "lucide-react"
import type { AccessKeyResponse } from "@/client/model"
import {
useAccessKeysQuery,
useCreateAccessKeyMutation,
useDeleteAccessKeyMutation,
} from "@/hooks/useAccessKeysQuery"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { t } from "@/i18n/T"
export function AccessKeysPage() { export function AccessKeysPage() {
const { data: keys = [], isLoading } = useAccessKeysQuery(); const { data: keys = [], isLoading } = useAccessKeysQuery()
const createMutation = useCreateAccessKeyMutation(); const createMutation = useCreateAccessKeyMutation()
const deleteMutation = useDeleteAccessKeyMutation(); const deleteMutation = useDeleteAccessKeyMutation()
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false)
const [addForm, setAddForm] = useState({ name: "" }); const [addForm, setAddForm] = useState({ name: "" })
const [newKey, setNewKey] = useState<string | null>(null); const [newKey, setNewKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false)
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text)
setCopied(true); setCopied(true)
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000)
}; }
const handleAdd = async () => { const handleAdd = async () => {
if (!addForm.name.trim()) { if (!addForm.name.trim()) {
setMessage({ type: "error", text: t("settings.access_keys.messages.name_required") }); setMessage({
return; type: "error",
text: t("settings.access_keys.messages.name_required"),
})
return
} }
try { try {
const res = await createMutation.mutateAsync({ name: addForm.name.trim(), scopes: [] }); const res = await createMutation.mutateAsync({
// The API returns the new key only once in the response name: addForm.name.trim(),
scopes: [],
})
if (res?.access_key) { if (res?.access_key) {
setNewKey(res.access_key); setNewKey(res.access_key)
} }
setMessage({ type: "success", text: t("settings.access_keys.messages.created_success") }); setMessage({
setShowAdd(false); type: "success",
setAddForm({ name: "" }); text: t("settings.access_keys.messages.created_success"),
})
setShowAdd(false)
setAddForm({ name: "" })
} catch { } catch {
setMessage({ type: "error", text: t("settings.access_keys.messages.create_failed") }); setMessage({
type: "error",
text: t("settings.access_keys.messages.create_failed"),
})
} }
}; }
const handleDelete = async (keyId: number) => { const handleDelete = async (keyId: number) => {
try { try {
await deleteMutation.mutateAsync(keyId); await deleteMutation.mutateAsync(keyId)
setMessage({ type: "success", text: t("settings.access_keys.messages.delete_success") }); setMessage({
type: "success",
text: t("settings.access_keys.messages.delete_success"),
})
} catch { } catch {
setMessage({ type: "error", text: t("settings.access_keys.messages.delete_failed") }); setMessage({
type: "error",
text: t("settings.access_keys.messages.delete_failed"),
})
} }
}; }
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div <Loader2
className="w-6 h-6 rounded-full border-2 border-muted border-t-accent animate-spin" className="size-6 animate-spin"
style={{ borderColor: "var(--border-default)", borderTopColor: "var(--accent)" }} style={{ color: "var(--text-muted)" }}
/> />
</div> </div>
); )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between gap-4">
<h1 className="text-[20px] font-bold" style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.title")}</h1> <div className="flex min-w-0 flex-col gap-2">
<h1
className="text-[20px] font-bold"
style={{ color: "var(--text-primary)" }}
>
{t("settings.access_keys.title")}
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
{t("settings.access_keys.description")}
</p>
</div>
<Button <Button
onClick={() => { onClick={() => {
setShowAdd(true); setShowAdd(true)
setNewKey(null); setNewKey(null)
setMessage(null); setMessage(null)
}} }}
size="sm" size="sm"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
> >
<Plus className="w-3.5 h-3.5 mr-1.5" /> <Plus className="mr-2 size-4" />
{t("settings.access_keys.generate_button")} {t("settings.access_keys.generate_button")}
</Button> </Button>
</div> </div>
<p className="text-[13px] mb-6" style={{ color: "var(--text-muted)" }}>
{t("settings.access_keys.description")}
</p>
{/* New Token Display */} {message && !newKey && (
{newKey && ( <Alert
<div className="mb-6 p-4 rounded-lg" style={{ backgroundColor: "var(--success-alpha10)", border: "1px solid var(--success)" }}> variant={message.type === "error" ? "destructive" : undefined}
<h3 className="text-[14px] font-semibold mb-2" style={{ color: "var(--success)" }}> className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
{t("settings.access_keys.copy_warning")} >
</h3> <AlertCircle className="size-4" />
<div className="flex items-center gap-2"> <AlertDescription>{message.text}</AlertDescription>
<div className="flex-1 font-mono text-sm p-2 rounded border truncate" style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--success)", color: "var(--text-primary)" }}> </Alert>
{newKey}
</div>
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newKey)}>
{copied ? <Check className="w-4 h-4" style={{ color: "var(--success)" }} /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
)} )}
{/* Add Form */} {newKey && (
{showAdd && !newKey && ( <Card
<div className="mb-6 p-4 rounded-lg border" style={{ borderColor: "var(--accent)", backgroundColor: "var(--surface-elevated)" }}> className="bg-[var(--success-alpha10)]"
<h3 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.generate_button")}</h3> style={{
<div className="space-y-4"> borderColor: "color-mix(in srgb, var(--success) 30%, transparent)",
<div> }}
<Label className="text-[12px] font-semibold uppercase mb-1 block" style={{ color: "var(--text-muted)" }}>{t("settings.access_keys.token_name")}</Label> >
<Input <CardHeader className="gap-2">
value={addForm.name} <CardTitle className="text-[15px] text-[var(--success)]">
onChange={(e) => setAddForm({ name: e.target.value })} {t("settings.access_keys.copy_warning")}
placeholder="e.g. CLI Token" </CardTitle>
style={{ backgroundColor: "var(--input-bg)", border: "none" }} <CardDescription>
/> This token is shown once. Copy it now and store it securely.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div
className="rounded-xl border bg-[var(--surface-ground)] p-3 font-mono text-[13px] text-[var(--text-primary)]"
style={{
borderColor:
"color-mix(in srgb, var(--success) 30%, transparent)",
}}
>
{newKey}
</div> </div>
<div className="flex gap-2"> <div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newKey)}
>
{copied ? (
<Check className="mr-2 size-4" />
) : (
<Copy className="mr-2 size-4" />
)}
{copied ? "Copied" : "Copy token"}
</Button>
</div>
</CardContent>
</Card>
)}
{showAdd && !newKey && (
<Card>
<CardHeader className="gap-2">
<CardTitle className="text-[15px]">
{t("settings.access_keys.generate_button")}
</CardTitle>
<CardDescription>
Create a token for scripts, CLI tools, or local integrations.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="access-key-name">
<FieldTitle>
{t("settings.access_keys.token_name")}
</FieldTitle>
<FieldDescription>
Give the token a recognizable name.
</FieldDescription>
</FieldLabel>
<FieldContent>
<Input
id="access-key-name"
value={addForm.name}
onChange={(e) => setAddForm({ name: e.target.value })}
placeholder="e.g. CLI Token"
/>
</FieldContent>
</Field>
</FieldGroup>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowAdd(false)
setAddForm({ name: "" })
}}
>
{t("common.actions.cancel")}
</Button>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
disabled={createMutation.isPending} disabled={createMutation.isPending}
size="sm" size="sm"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
> >
{createMutation.isPending && ( {createMutation.isPending && (
<div <Loader2 className="mr-2 size-4 animate-spin" />
className="w-3.5 h-3.5 mr-1.5 rounded-full border-2 border-accent-fg/30 border-t-accent-fg animate-spin"
style={{ borderColor: "color-mix(in srgb, var(--accent-fg) 30%, transparent)", borderTopColor: "var(--accent-fg)" }}
/>
)} )}
{t("settings.access_keys.generate_token")} {t("settings.access_keys.generate_token")}
</Button> </Button>
<Button onClick={() => setShowAdd(false)} variant="ghost" size="sm" style={{ color: "var(--text-secondary)" }}>{t("common.actions.cancel")}</Button>
</div> </div>
</div> </CardContent>
</div> </Card>
)} )}
{/* Keys List */}
{keys.length === 0 ? ( {keys.length === 0 ? (
<div className="text-center py-12 rounded-lg border" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}> <Empty className="border border-dashed border-[var(--border-subtle)] bg-[var(--surface-secondary)]/70 py-12">
<Key className="w-10 h-10 mx-auto mb-3 opacity-20" style={{ color: "var(--text-muted)" }} /> <EmptyHeader>
<p style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.empty")}</p> <EmptyMedia variant="icon">
</div> <Key />
</EmptyMedia>
<EmptyTitle>{t("settings.access_keys.empty")}</EmptyTitle>
<EmptyDescription>
Access tokens let you automate against the API without using your
password.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : ( ) : (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{keys.map((key: AccessKeyResponse) => ( {keys.map((key: AccessKeyResponse) => (
<div <Card key={key.id} size="sm">
key={key.id} <CardContent className="flex flex-col gap-4 pt-4">
className="p-4 rounded-lg border flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }} <div className="flex items-start justify-between gap-3">
> <div className="min-w-0 flex-1">
<div className="min-w-0"> <p
<p className="text-[14px] font-semibold truncate" style={{ color: "var(--text-primary)" }}>{key.name}</p> className="truncate text-[14px] font-semibold"
<div className="flex items-center gap-3 mt-1 text-[12px]" style={{ color: "var(--text-muted)" }}> style={{ color: "var(--text-primary)" }}
<span>{t("settings.access_keys.created_on")} {format(new Date(key.created_at), "MMM d, yyyy")}</span> >
{key.expires_at && ( {key.name}
<> </p>
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: "var(--text-muted)" }} /> <div className="mt-1 flex flex-wrap items-center gap-2 text-[12px]">
<span>{t("settings.access_keys.expires")} {format(new Date(key.expires_at), "MMM d, yyyy")}</span> <Badge
</> variant="outline"
)} className="rounded-full px-2.5 py-1"
>
{t("settings.access_keys.created_on")}{" "}
{format(new Date(key.created_at), "MMM d, yyyy")}
</Badge>
{key.expires_at && (
<Badge
variant="secondary"
className="rounded-full px-2.5 py-1"
>
{t("settings.access_keys.expires")}{" "}
{format(new Date(key.expires_at), "MMM d, yyyy")}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDelete(key.id)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</div>
<Button <Separator />
variant="ghost"
size="sm" <div className="text-[12px] text-muted-foreground">
className="text-destructive hover:text-destructive hover:bg-destructive/10" Key #{key.id} will be removed immediately after deletion.
onClick={() => handleDelete(key.id)} </div>
> </CardContent>
<Trash2 className="w-4 h-4" /> </Card>
</Button>
</div>
))} ))}
</div> </div>
)} )}
{message && !newKey && (
<div className={`mt-4 text-[13px]`} style={{ color: message.type === "success" ? "var(--success)" : "var(--destructive)" }}>
{message.text}
</div>
)}
</div> </div>
); )
} }

View File

@ -1,311 +1,371 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { getPreferences, updatePreferences } from "@/client/api"; import { Loader2, Palette, Sparkles } from "lucide-react"
import type { PreferencesResponse } from "@/client/model"; import { getPreferences, updatePreferences } from "@/client/api"
import { Button } from "@/components/ui/button"; import type { PreferencesResponse } from "@/client/model"
import { Label } from "@/components/ui/label"; import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select"
import { Loader2, Palette } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { SETTINGS_PAGE } from "@/css/app/styles"; import { ThemeCustomization } from "@/components/theme/ThemeCustomization"
import { ThemeCustomization } from "@/components/theme/ThemeCustomization"; import { ThemePresetSelector } from "@/components/theme/ThemePresetSelector"
import { ThemePresetSelector } from "@/components/theme/ThemePresetSelector"; import { t } from "@/i18n/T"
import { t } from "@/i18n/T";
const LANGUAGES = [ const LANGUAGES = [
{ value: "zh-CN", label: "简体中文" }, { value: "zh-CN", label: "简体中文" },
{ value: "zh-TW", label: "繁體中文" }, { value: "zh-TW", label: "繁體中文" },
{ value: "en", label: "English" }, { value: "en", label: "English" },
{ value: "ja", label: "日本語" }, { value: "ja", label: "日本語" },
]; ]
const THEMES = [ const THEMES = [
{ value: "dark", label: t("settings.appearance_page.dark") }, { value: "dark", label: t("settings.appearance_page.dark") },
{ value: "light", label: t("settings.appearance_page.light") }, { value: "light", label: t("settings.appearance_page.light") },
{ value: "system", label: t("settings.appearance_page.system") }, { value: "system", label: t("settings.appearance_page.system") },
]; ]
const TIMEZONES = [ const TIMEZONES = [
{ value: "Asia/Shanghai", label: t("settings.appearance.timezone_asia_shanghai") || "Asia/Shanghai (UTC+8)" }, {
{ value: "Asia/Tokyo", label: t("settings.appearance.timezone_asia_tokyo") || "Asia/Tokyo (UTC+9)" }, value: "Asia/Shanghai",
{ value: "America/New_York", label: t("settings.appearance.timezone_america_ny") || "America/New_York (UTC-5)" }, label:
{ value: "America/Los_Angeles", label: t("settings.appearance.timezone_america_la") || "America/Los_Angeles (UTC-8)" }, t("settings.appearance.timezone_asia_shanghai") ||
{ value: "Europe/London", label: t("settings.appearance.timezone_europe_london") || "Europe/London (UTC+0)" }, "Asia/Shanghai (UTC+8)",
},
{
value: "Asia/Tokyo",
label: t("settings.appearance.timezone_asia_tokyo") || "Asia/Tokyo (UTC+9)",
},
{
value: "America/New_York",
label:
t("settings.appearance.timezone_america_ny") ||
"America/New_York (UTC-5)",
},
{
value: "America/Los_Angeles",
label:
t("settings.appearance.timezone_america_la") ||
"America/Los_Angeles (UTC-8)",
},
{
value: "Europe/London",
label:
t("settings.appearance.timezone_europe_london") ||
"Europe/London (UTC+0)",
},
{ value: "UTC", label: "UTC" }, { value: "UTC", label: "UTC" },
]; ]
const SelectField = ({ function SelectField({
label, label,
value, value,
onChange, onChange,
options, options,
description,
}: { }: {
label: string; label: string
value: string; value: string
onChange: (v: string) => void; onChange: (v: string) => void
options: { value: string; label: string }[]; options: { value: string; label: string }[]
}) => ( description: string
<div> }) {
<Label return (
className={SETTINGS_PAGE.formLabel} <Field>
style={{ color: "var(--text-muted)" }} <FieldLabel>
> <FieldTitle>{label}</FieldTitle>
{label} <FieldDescription>{description}</FieldDescription>
</Label> </FieldLabel>
<Select value={value} onValueChange={onChange}> <FieldContent>
<SelectTrigger <Select value={value} onValueChange={onChange}>
className="w-[260px] text-[14px]" <SelectTrigger>
style={{ <SelectValue />
backgroundColor: "var(--surface-elevated)", </SelectTrigger>
borderColor: "var(--border-default)", <SelectContent>
color: "var(--text-primary)", {options.map((o) => (
}} <SelectItem key={o.value} value={o.value}>
> {o.label}
<SelectValue /> </SelectItem>
</SelectTrigger> ))}
<SelectContent </SelectContent>
style={{ </Select>
backgroundColor: "var(--surface-elevated)", </FieldContent>
borderColor: "var(--border-default)", </Field>
color: "var(--text-primary)", )
}} }
>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
export function AppearancePage() { export function AppearancePage() {
const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache(); const { preferences: cachedPrefs, setPreferences: setCachedPrefs } =
const [, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs); useSettingsDataCache()
const [loading, setLoading] = useState(!cachedPrefs); const [, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs)
const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(!cachedPrefs)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ const [form, setForm] = useState({
language: cachedPrefs?.language ?? "zh-CN", language: cachedPrefs?.language ?? "zh-CN",
theme: cachedPrefs?.theme ?? "dark", theme: cachedPrefs?.theme ?? "dark",
timezone: cachedPrefs?.timezone ?? "Asia/Shanghai", timezone: cachedPrefs?.timezone ?? "Asia/Shanghai",
}); })
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
useEffect(() => { useEffect(() => {
if (cachedPrefs) return; if (cachedPrefs) return
(async () => { ;(async () => {
try { try {
const res = await getPreferences(); const res = await getPreferences()
const d = res.data.data!; const data = res.data.data!
setPrefs(d); setPrefs(data)
setCachedPrefs(d); setCachedPrefs(data)
setForm({ setForm({
language: d.language, language: data.language,
theme: d.theme, theme: data.theme,
timezone: d.timezone, timezone: data.timezone,
}); })
} catch { } catch {
setMessage({ type: "error", text: t("settings.appearance_page.load_failed") }); setMessage({
type: "error",
text: t("settings.appearance_page.load_failed"),
})
} finally { } finally {
setLoading(false); setLoading(false)
} }
})(); })()
}, [cachedPrefs, setCachedPrefs]); }, [cachedPrefs, setCachedPrefs])
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true); setSaving(true)
setMessage(null); setMessage(null)
await updatePreferences({ await updatePreferences({
language: form.language, language: form.language,
theme: form.theme, theme: form.theme,
timezone: form.timezone, timezone: form.timezone,
}); })
setMessage({ type: "success", text: t("settings.appearance_page.save_success") }); setMessage({
type: "success",
text: t("settings.appearance_page.save_success"),
})
} catch { } catch {
setMessage({ type: "error", text: t("settings.appearance_page.save_failed") }); setMessage({
type: "error",
text: t("settings.appearance_page.save_failed"),
})
} finally { } finally {
setSaving(false); setSaving(false)
} }
}; }
if (loading) { if (loading) {
return ( return (
<div className={SETTINGS_PAGE.loadingState}> <div className="flex items-center justify-center py-20">
<Loader2 <Loader2
className="w-6 h-6 animate-spin" className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
/> />
</div> </div>
); )
} }
const themes = [
{ value: "dark", label: t("settings.appearance_page.dark") },
{ value: "light", label: t("settings.appearance_page.light") },
{ value: "system", label: t("settings.appearance_page.system") },
];
return ( return (
<div> <div className="flex flex-col gap-6">
<h1 className={SETTINGS_PAGE.pageHeader} style={{ color: "var(--text-primary)" }}> <div className="flex flex-col gap-2">
{t("settings.appearance_page.title")} <h1
</h1> className="text-[20px] font-bold"
<p className={SETTINGS_PAGE.pageSubtitle} style={{ color: "var(--text-muted)" }}> style={{ color: "var(--text-primary)" }}
{t("settings.appearance_page.subtitle")}
</p>
<Tabs defaultValue="preset" className="mt-6">
<TabsList
style={{
backgroundColor: "var(--surface-elevated)",
border: "1px solid var(--border-default)",
}}
> >
<TabsTrigger {t("settings.appearance_page.title")}
value="preset" </h1>
style={{ color: "var(--text-secondary)" }} <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
> {t("settings.appearance_page.subtitle")}
</p>
</div>
<Tabs defaultValue="preset" className="flex flex-col gap-4">
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-1">
<TabsTrigger value="preset" className="rounded-xl text-[13px]">
{t("settings.appearance_page.theme_scheme")} {t("settings.appearance_page.theme_scheme")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="basic" className="rounded-xl text-[13px]">
value="basic"
style={{ color: "var(--text-secondary)" }}
>
{t("settings.appearance_page.basic_settings") || "Basic Settings"} {t("settings.appearance_page.basic_settings") || "Basic Settings"}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="custom" className="rounded-xl text-[13px]">
value="custom" <Palette data-icon="inline-start" />
style={{ color: "var(--text-secondary)" }}
>
<Palette className="w-4 h-4 mr-1.5" />
{t("settings.appearance_page.custom") || "Custom"} {t("settings.appearance_page.custom") || "Custom"}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="preset" className="mt-4"> <TabsContent value="preset" className="mt-0">
<h3 className="text-[14px] font-semibold mb-4" style={{ color: "var(--text-primary)" }}> <Card>
{t("settings.appearance_page.select_theme_scheme")} <CardHeader className="gap-2">
</h3> <CardTitle className="flex items-center gap-2 text-[15px]">
<ThemePresetSelector /> <Sparkles className="size-4" />
{t("settings.appearance_page.select_theme_scheme")}
</CardTitle>
<CardDescription>
Choose a preset that matches your preferred visual tone.
</CardDescription>
</CardHeader>
<CardContent>
<ThemePresetSelector />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="basic" className="mt-4"> <TabsContent value="basic" className="mt-0">
<div className={SETTINGS_PAGE.formSection}> <Card>
<div> <CardHeader className="gap-2">
<h3 <CardTitle className="text-[15px]">
className="text-[14px] font-semibold mb-4" {t("settings.appearance_page.basic_settings") ||
style={{ color: "var(--text-primary)" }} "Basic Settings"}
> </CardTitle>
{t("settings.appearance_page.theme")} <CardDescription>
</h3> Tune the general locale and theme behavior for the app.
<div className="flex gap-3"> </CardDescription>
{themes.map((themeOption) => ( </CardHeader>
<button <CardContent className="flex flex-col gap-6">
key={themeOption.value} <FieldGroup>
onClick={() => setForm((f) => ({ ...f, theme: themeOption.value }))} <Field>
className="flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all" <FieldLabel>
style={{ <FieldTitle>
width: "100px", {t("settings.appearance_page.theme")}
borderColor: </FieldTitle>
form.theme === themeOption.value <FieldDescription>
? "var(--accent)" Select the default color mode for the interface.
: "var(--border-default)", </FieldDescription>
backgroundColor: </FieldLabel>
form.theme === themeOption.value <FieldContent>
? "var(--hover-bg-strong)" <ToggleGroup
: "var(--surface-elevated)", type="single"
}} value={form.theme}
> onValueChange={(v) => {
<div if (!v) return
className="w-10 h-10 rounded-full flex items-center justify-center text-[11px] font-bold" setForm((f) => ({ ...f, theme: v }))
style={{ }}
backgroundColor: className="grid w-full grid-cols-3"
themeOption.value === "dark" >
? "#1E1F22" {THEMES.map((themeOption) => (
: themeOption.value === "light" <ToggleGroupItem
? "#F2F3F5" key={themeOption.value}
: "linear-gradient(135deg, #1E1F22 50%, #F2F3F5 50%)", value={themeOption.value}
color: variant="outline"
themeOption.value === "dark" className="flex h-auto flex-col gap-3 rounded-2xl px-4 py-4 text-center"
? "#DBDEE1" >
: themeOption.value === "light" <div
? "#313338" className="flex size-10 items-center justify-center rounded-full text-[11px] font-semibold"
: "#5865F2", style={{
}} background:
> themeOption.value === "dark"
{themeOption.value === "dark" ? "D" : themeOption.value === "light" ? "L" : "S"} ? "#1E1F22"
</div> : themeOption.value === "light"
<span ? "#F2F3F5"
className="text-[12px]" : "linear-gradient(135deg, #1E1F22 50%, #F2F3F5 50%)",
style={{ color: "var(--text-primary)" }} color:
> themeOption.value === "dark"
{themeOption.label} ? "#DBDEE1"
</span> : themeOption.value === "light"
</button> ? "#313338"
))} : "#5865F2",
</div> }}
</div> >
{themeOption.value === "dark"
? "D"
: themeOption.value === "light"
? "L"
: "S"}
</div>
<span className="text-[13px]">
{themeOption.label}
</span>
</ToggleGroupItem>
))}
</ToggleGroup>
</FieldContent>
</Field>
<SelectField <SelectField
label={t("settings.appearance_page.language")} label={t("settings.appearance_page.language")}
value={form.language} description={t("settings.appearance_page.language")}
onChange={(v) => setForm((f) => ({ ...f, language: v }))} value={form.language}
options={LANGUAGES} onChange={(v) => setForm((f) => ({ ...f, language: v }))}
/> options={LANGUAGES}
/>
<SelectField <SelectField
label={t("settings.appearance_page.timezone")} label={t("settings.appearance_page.timezone")}
value={form.timezone} description={t("settings.appearance_page.timezone")}
onChange={(v) => setForm((f) => ({ ...f, timezone: v }))} value={form.timezone}
options={TIMEZONES} onChange={(v) => setForm((f) => ({ ...f, timezone: v }))}
/> options={TIMEZONES}
</div> />
</FieldGroup>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="custom" className="mt-0">
<Card>
<CardHeader className="gap-2">
<CardTitle className="text-[15px]">
{t("settings.appearance_page.custom") || "Custom"}
</CardTitle>
<CardDescription>
Adjust token-level theme variables and fine-grained styling.
</CardDescription>
</CardHeader>
<CardContent>
<ThemeCustomization />
</CardContent>
</Card>
</TabsContent>
</Tabs>
{message && ( {message && (
<div <Alert
className={SETTINGS_PAGE.message} variant={message.type === "error" ? "destructive" : undefined}
style={{ className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
color:
message.type === "success"
? "var(--success)"
: "var(--destructive)",
}}
> >
{message.text} <AlertDescription>{message.text}</AlertDescription>
</div> </Alert>
)} )}
<div className={SETTINGS_PAGE.buttonRowSimple}> <div className="flex items-center justify-end gap-3">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[11px]">
{form.theme}
</Badge>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
size="sm" size="sm"
style={{ className="min-w-28"
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
> >
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />} {saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("settings.appearance_page.save_button")} {t("settings.appearance_page.save_button")}
</Button> </Button>
</div> </div>
</TabsContent>
<TabsContent value="custom" className="mt-4">
<ThemeCustomization />
</TabsContent>
</Tabs>
</div> </div>
); )
} }

View File

@ -1,140 +1,308 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"
import { userBilling, userBillingErrors, userBillingHistory } from "@/client/api"; import {
import type { UserBillingResponse, UserBillingErrorsResponse, UserBillingHistoryResponse } from "@/client/model"; userBilling,
import { Loader2, DollarSign, TrendingUp, AlertTriangle, CreditCard } from "lucide-react"; userBillingErrors,
import { t } from "@/i18n/T"; userBillingHistory,
} from "@/client/api"
import type {
UserBillingResponse,
UserBillingErrorsResponse,
UserBillingHistoryResponse,
} from "@/client/model"
import {
Loader2,
DollarSign,
TrendingUp,
AlertTriangle,
CreditCard,
} from "lucide-react"
import { t } from "@/i18n/T"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Separator } from "@/components/ui/separator"
export function BillingPage() { export function BillingPage() {
const { data: billing, isLoading: bLoading } = useQuery({ const { data: billing, isLoading: bLoading } = useQuery({
queryKey: ["user-billing"], queryKey: ["user-billing"],
queryFn: async () => { queryFn: async () => {
const res = await userBilling(); const res = await userBilling()
return res.data?.data as UserBillingResponse; return res.data?.data as UserBillingResponse
}, },
staleTime: 30_000, staleTime: 30_000,
}); })
const { data: errors } = useQuery({ const { data: errors } = useQuery({
queryKey: ["user-billing-errors"], queryKey: ["user-billing-errors"],
queryFn: async () => { queryFn: async () => {
const res = await userBillingErrors(); const res = await userBillingErrors()
return res.data?.data as UserBillingErrorsResponse; return res.data?.data as UserBillingErrorsResponse
}, },
staleTime: 15_000, staleTime: 15_000,
}); })
const { data: history, isLoading: hLoading } = useQuery({ const { data: history, isLoading: hLoading } = useQuery({
queryKey: ["user-billing-history"], queryKey: ["user-billing-history"],
queryFn: async () => { queryFn: async () => {
const res = await userBillingHistory(); const res = await userBillingHistory()
return res.data?.data as UserBillingHistoryResponse; return res.data?.data as UserBillingHistoryResponse
}, },
staleTime: 30_000, staleTime: 30_000,
}); })
if (bLoading) return <div className="flex justify-center py-10"><Loader2 className="w-5 h-5 animate-spin" style={{ color: "var(--text-muted)" }} /></div>; if (bLoading) {
if (!billing) return <div className="text-center py-10" style={{ color: "var(--destructive)" }}>{t("settings.billing.load_failed")}</div>; return (
<div className="flex justify-center py-10">
<Loader2
className="size-5 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div>
)
}
const hasErrors = !!errors?.list && errors.list.length > 0; if (!billing) {
return (
<Alert
variant="destructive"
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
>
<AlertTriangle className="size-4" />
<AlertDescription>{t("settings.billing.load_failed")}</AlertDescription>
</Alert>
)
}
const hasErrors = !!errors?.list && errors.list.length > 0
return ( return (
<div className="space-y-8"> <div className="space-y-6">
{/* Billing Errors Banner */}
{hasErrors && errors?.list && ( {hasErrors && errors?.list && (
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--destructive-alpha10)", border: "1px solid var(--destructive)" }}> <Alert
<div className="flex items-center gap-2 mb-3"> variant="destructive"
<AlertTriangle className="w-4 h-4" style={{ color: "var(--destructive)" }} /> className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
<h2 className="text-[14px] font-semibold" style={{ color: "var(--destructive)" }}>{t("settings.billing.billing_errors")}</h2> >
</div> <AlertTriangle className="size-4" />
<div className="space-y-2"> <AlertTitle>{t("settings.billing.billing_errors")}</AlertTitle>
{errors!.list.map(err => ( <AlertDescription className="space-y-2">
<div key={err.id} className="p-3 rounded-md" style={{ backgroundColor: "var(--surface-ground)" }}> {errors.list.map((err) => (
<div className="flex items-center justify-between mb-1"> <div
<span className="text-[12px] font-semibold" style={{ color: "var(--destructive)" }}> key={err.id}
{err.error_type === "insufficient_balance" ? t("settings.billing.insufficient_balance") : err.error_type} className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-3"
>
<div className="mb-1 flex items-center justify-between gap-3">
<span
className="text-[12px] font-semibold"
style={{ color: "var(--destructive)" }}
>
{err.error_type === "insufficient_balance"
? t("settings.billing.insufficient_balance")
: err.error_type}
</span> </span>
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}> <span
className="text-[11px]"
style={{ color: "var(--text-muted)" }}
>
{new Date(err.created_at).toLocaleString()} {new Date(err.created_at).toLocaleString()}
</span> </span>
</div> </div>
<p className="text-[13px]" style={{ color: "var(--text-primary)" }}>{err.message}</p> <p
className="text-[13px]"
style={{ color: "var(--text-primary)" }}
>
{err.message}
</p>
</div> </div>
))} ))}
</div> </AlertDescription>
</section> </Alert>
)} )}
{/* Current Balance */} <Card>
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}> <CardHeader className="gap-2">
<h2 className="text-[14px] font-semibold mb-4" style={{ color: "var(--text-primary)" }}>{t("settings.billing.personal_billing")}</h2> <CardTitle className="text-[15px]">
{t("settings.billing.personal_billing")}
<div className="grid grid-cols-1 gap-4 mb-4"> </CardTitle>
<div className="p-4 rounded-md" style={{ backgroundColor: "var(--surface-ground)" }}> <CardDescription>
<div className="flex items-center gap-2 mb-1"> {billing.is_pro ? "Pro plan billing" : "Free plan billing"}
<DollarSign className="w-4 h-4" style={{ color: "var(--success)" }} /> </CardDescription>
<span className="text-[11px] uppercase font-semibold" style={{ color: "var(--text-muted)" }}>{t("settings.billing.balance")}</span> </CardHeader>
</div> <CardContent className="grid gap-4">
<p className="text-[24px] font-bold" style={{ color: "var(--text-primary)" }}> <div className="grid gap-4 md:grid-cols-2">
{billing.currency} {billing.balance.toFixed(2)} <div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4">
</p> <div className="mb-1 flex items-center gap-2">
</div> <DollarSign
{billing.is_pro && ( className="size-4"
<div className="p-4 rounded-md" style={{ backgroundColor: "var(--surface-ground)" }}> style={{ color: "var(--success)" }}
<div className="flex items-center gap-2 mb-1"> />
<TrendingUp className="w-4 h-4" style={{ color: "var(--accent)" }} /> <span
<span className="text-[11px] uppercase font-semibold" style={{ color: "var(--text-muted)" }}>{t("settings.billing.monthly_quota")}</span> className="text-[11px] font-semibold uppercase"
style={{ color: "var(--text-muted)" }}
>
{t("settings.billing.balance")}
</span>
</div> </div>
<p className="text-[24px] font-bold" style={{ color: "var(--text-primary)" }}> <p
className="text-[24px] font-bold"
style={{ color: "var(--text-primary)" }}
>
{billing.currency} {billing.balance.toFixed(2)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4">
<div className="mb-1 flex items-center gap-2">
<TrendingUp
className="size-4"
style={{ color: "var(--accent)" }}
/>
<span
className="text-[11px] font-semibold uppercase"
style={{ color: "var(--text-muted)" }}
>
{t("settings.billing.monthly_quota")}
</span>
</div>
<p
className="text-[24px] font-bold"
style={{ color: "var(--text-primary)" }}
>
{billing.currency} {billing.monthly_quota.toFixed(2)} {billing.currency} {billing.monthly_quota.toFixed(2)}
</p> </p>
</div> </div>
</div>
{billing.is_pro && billing.monthly_quota > 0 && (
<div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4">
<div className="mb-1 flex items-center justify-between gap-3 text-[12px]">
<span style={{ color: "var(--text-muted)" }}>
{t("settings.billing.monthly_usage")}
</span>
<span style={{ color: "var(--text-primary)" }}>
{billing.currency} {billing.month_used.toFixed(2)} /{" "}
{billing.currency} {billing.monthly_quota.toFixed(2)}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
<div
className="h-2 rounded-full transition-all"
style={{
width: `${Math.min(100, (billing.month_used / billing.monthly_quota) * 100)}%`,
backgroundColor:
billing.month_used / billing.monthly_quota > 0.9
? "var(--destructive)"
: "var(--success)",
}}
/>
</div>
</div>
)} )}
</div>
{billing.is_pro && billing.monthly_quota > 0 && ( <div className="flex items-center gap-2">
<div className="mb-2"> <Badge
<div className="flex justify-between text-[12px] mb-1"> variant="outline"
<span style={{ color: "var(--text-muted)" }}>{t("settings.billing.monthly_usage")}</span> className="rounded-full px-2.5 py-1 text-[11px]"
<span style={{ color: "var(--text-primary)" }}>{billing.currency} {billing.month_used.toFixed(2)} / {billing.currency} {billing.monthly_quota.toFixed(2)}</span> >
</div> <CreditCard className="size-3.5" />
<div className="w-full h-2 rounded-full" style={{ backgroundColor: "var(--border-default)" }}> {billing.currency} {billing.is_pro ? "Pro" : "Free"}
{billing.monthly_quota > 0 && ( </Badge>
<div className="h-2 rounded-full transition-all" style={{ width: `${Math.min(100, (billing.month_used / billing.monthly_quota) * 100)}%`, backgroundColor: billing.month_used / billing.monthly_quota > 0.9 ? "var(--destructive)" : "var(--success)" }} />
)}
</div>
</div> </div>
)} </CardContent>
</Card>
<div className="flex items-center gap-4 text-[12px]" style={{ color: "var(--text-muted)" }}> <Card>
<div className="flex items-center gap-1"> <CardHeader className="gap-2">
<CreditCard className="w-3 h-3" /> <CardTitle className="text-[15px]">
{billing.currency} {billing.is_pro ? "Pro" : "Free"} {t("settings.billing.history")}{" "}
</div> {history ? `(${history.total})` : ""}
</div> </CardTitle>
</section> <CardDescription>Recent billing activity</CardDescription>
</CardHeader>
{/* Billing History */} <CardContent>
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}> {hLoading ? (
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}> <div className="flex justify-center py-6">
{t("settings.billing.history")} {history ? `(${history.total})` : ""} <Loader2 className="size-4 animate-spin" />
</h2> </div>
{hLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : ) : !history?.list?.length ? (
!history?.list?.length ? <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>{t("settings.billing.no_history")}</p> : <Empty className="border-0 bg-transparent py-10">
<div className="overflow-hidden rounded-lg" style={{ border: "1px solid var(--border-default)" }}> <EmptyHeader>
<EmptyMedia variant="icon">
<CreditCard />
</EmptyMedia>
<EmptyTitle>{t("settings.billing.no_history")}</EmptyTitle>
<EmptyDescription>
No billing activity has been recorded yet.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--border-subtle)]">
<table className="w-full text-[13px]"> <table className="w-full text-[13px]">
<thead> <thead>
<tr style={{ backgroundColor: "var(--surface-ground)", borderBottom: "1px solid var(--border-default)" }}> <tr className="bg-[var(--surface-ground)]">
<th className="text-left px-3 py-2" style={{ color: "var(--text-muted)" }}>{t("settings.billing.date")}</th> <th
<th className="text-left px-3 py-2" style={{ color: "var(--text-muted)" }}>{t("settings.billing.reason")}</th> className="px-3 py-2 text-left"
<th className="text-right px-3 py-2" style={{ color: "var(--text-muted)" }}>{t("settings.billing.amount")}</th> style={{ color: "var(--text-muted)" }}
>
{t("settings.billing.date")}
</th>
<th
className="px-3 py-2 text-left"
style={{ color: "var(--text-muted)" }}
>
{t("settings.billing.reason")}
</th>
<th
className="px-3 py-2 text-right"
style={{ color: "var(--text-muted)" }}
>
{t("settings.billing.amount")}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{history.list.map(item => ( {history.list.map((item, index) => (
<tr key={item.uid} style={{ borderBottom: "1px solid var(--border-subtle)" }}> <tr
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{new Date(item.created_at).toLocaleDateString()}</td> key={item.uid}
<td className="px-3 py-2" style={{ color: "var(--text-primary)" }}>{item.reason}</td> className={
<td className="px-3 py-2 text-right" style={{ color: Number(item.amount) < 0 ? "var(--destructive)" : "var(--success)" }}> index < history.list.length - 1
? "border-b border-[var(--border-subtle)]"
: ""
}
>
<td
className="px-3 py-2"
style={{ color: "var(--text-muted)" }}
>
{new Date(item.created_at).toLocaleDateString()}
</td>
<td
className="px-3 py-2"
style={{ color: "var(--text-primary)" }}
>
{item.reason}
</td>
<td
className="px-3 py-2 text-right"
style={{
color:
Number(item.amount) < 0
? "var(--destructive)"
: "var(--success)",
}}
>
{billing.currency} {Number(item.amount).toFixed(4)} {billing.currency} {Number(item.amount).toFixed(4)}
</td> </td>
</tr> </tr>
@ -142,8 +310,9 @@ export function BillingPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
} )}
</section> </CardContent>
</Card>
</div> </div>
); )
} }

View File

@ -1,184 +1,227 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { apiEmailGet, apiEmailChange } from "@/client/api"; import { AlertCircle, CheckCircle2, Loader2, Mail } from "lucide-react"
import { Button } from "@/components/ui/button"; import { apiEmailChange, apiEmailGet } from "@/client/api"
import { Input } from "@/components/ui/input"; import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"
import { Loader2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"; import { Button } from "@/components/ui/button"
import { SETTINGS_PAGE } from "@/css/app/styles"; import {
import { t } from "@/i18n/T"; Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { t } from "@/i18n/T"
export function EmailPage() { export function EmailPage() {
const { email: cachedEmail, setEmail: setCachedEmail } = useSettingsDataCache(); const { email: cachedEmail, setEmail: setCachedEmail } =
const [email, setEmail] = useState<string | null>(cachedEmail); useSettingsDataCache()
const [loading, setLoading] = useState(cachedEmail === null); const [email, setEmail] = useState<string | null>(cachedEmail)
const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(cachedEmail === null)
const [form, setForm] = useState({ new_email: "", password: "" }); const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ new_email: "", password: "" })
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
useEffect(() => { useEffect(() => {
if (cachedEmail !== null) return; if (cachedEmail !== null) return
(async () => { ;(async () => {
try { try {
const res = await apiEmailGet(); const res = await apiEmailGet()
const e = res.data.data?.email ?? null; const nextEmail = res.data.data?.email ?? null
setEmail(e); setEmail(nextEmail)
setCachedEmail(e); setCachedEmail(nextEmail)
} catch { } catch {
setMessage({ type: "error", text: t("settings.email_page.load_failed") }); setMessage({
type: "error",
text: t("settings.email_page.load_failed"),
})
} finally { } finally {
setLoading(false); setLoading(false)
} }
})(); })()
}, [cachedEmail, setCachedEmail]); }, [cachedEmail, setCachedEmail])
const handleSave = async () => { const handleSave = async () => {
if (!form.new_email || !form.password) { if (!form.new_email || !form.password) {
setMessage({ type: "error", text: t("settings.email_page.fill_all_fields") }); setMessage({
return; type: "error",
text: t("settings.email_page.fill_all_fields"),
})
return
} }
try { try {
setSaving(true); setSaving(true)
setMessage(null); setMessage(null)
await apiEmailChange({ await apiEmailChange({
new_email: form.new_email, new_email: form.new_email,
password: form.password, password: form.password,
}); })
setMessage({ setMessage({
type: "success", type: "success",
text: t("settings.email_page.verification_sent"), text: t("settings.email_page.verification_sent"),
}); })
setForm({ new_email: "", password: "" }); setForm({ new_email: "", password: "" })
} catch { } catch {
setMessage({ type: "error", text: t("settings.email_page.change_failed") }); setMessage({
type: "error",
text: t("settings.email_page.change_failed"),
})
} finally { } finally {
setSaving(false); setSaving(false)
} }
}; }
if (loading) { if (loading) {
return ( return (
<div className={SETTINGS_PAGE.loadingState}> <div className="flex items-center justify-center py-20">
<Loader2 <Loader2
className="w-6 h-6 animate-spin" className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
/> />
</div> </div>
); )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<h1 <div className="flex flex-col gap-2">
className={SETTINGS_PAGE.pageHeader} <h1
style={{ color: "var(--text-primary)" }} className="text-[20px] font-bold"
> style={{ color: "var(--text-primary)" }}
{t("settings.email_page.title")}
</h1>
<p
className={SETTINGS_PAGE.pageSubtitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.email_page.subtitle")}
</p>
<div className="mb-6">
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
> >
{t("settings.email_page.current_email")} {t("settings.email_page.title")}
</Label> </h1>
<div <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
className="text-[14px] px-3 py-2 rounded-md" {t("settings.email_page.subtitle")}
style={{ </p>
backgroundColor: "var(--surface-elevated)",
color: email ? "var(--text-primary)" : "var(--text-muted)",
}}
>
{email || t("settings.email_page.no_email_set")}
</div>
</div> </div>
<div className={SETTINGS_PAGE.formSection}> <Card>
<div className={SETTINGS_PAGE.formGroup}> <CardHeader className="gap-2">
<Label <CardTitle className="flex items-center gap-2 text-[15px]">
className={SETTINGS_PAGE.formLabel} <Mail className="size-4" />
style={{ color: "var(--text-muted)" }} {t("settings.email_page.current_email")}
</CardTitle>
<CardDescription>{t("settings.email_page.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-[12px] tracking-[0.2em] text-muted-foreground uppercase">
Current
</p>
<p
className="truncate text-[14px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{email || t("settings.email_page.no_email_set")}
</p>
</div>
<Badge
variant="outline"
className="rounded-full px-2.5 py-1 text-[11px]"
> >
{email ? "Verified" : "Unset"}
</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-2">
<CardTitle className="text-[15px]">
{t("settings.email_page.new_email")} {t("settings.email_page.new_email")}
</Label> </CardTitle>
<Input <CardDescription>
type="email"
value={form.new_email}
onChange={(e) =>
setForm((f) => ({ ...f, new_email: e.target.value }))
}
placeholder={t("settings.email.new_email_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
<div className={SETTINGS_PAGE.formGroup}>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.email_page.current_password")} {t("settings.email_page.current_password")}
</Label> </CardDescription>
<Input </CardHeader>
type="password" <CardContent className="flex flex-col gap-5">
value={form.password} <FieldGroup>
onChange={(e) => <Field>
setForm((f) => ({ ...f, password: e.target.value })) <FieldLabel htmlFor="new-email">
} <FieldTitle>{t("settings.email_page.new_email")}</FieldTitle>
placeholder={t("settings.email_page.current_password_placeholder")} <FieldDescription>
className={SETTINGS_PAGE.formInput} {t("settings.email.new_email_placeholder")}
style={{ </FieldDescription>
backgroundColor: "var(--surface-elevated)", </FieldLabel>
borderColor: "var(--border-default)", <FieldContent>
color: "var(--text-primary)", <Input
}} id="new-email"
/> type="email"
</div> value={form.new_email}
</div> onChange={(e) =>
setForm((f) => ({ ...f, new_email: e.target.value }))
}
placeholder={t("settings.email.new_email_placeholder")}
/>
</FieldContent>
</Field>
{message && ( <Field>
<div <FieldLabel htmlFor="current-email-password">
className={SETTINGS_PAGE.message} <FieldTitle>
style={{ {t("settings.email_page.current_password")}
color: </FieldTitle>
message.type === "success" <FieldDescription>
? "var(--success)" Confirm the change with your current password.
: "var(--destructive)", </FieldDescription>
}} </FieldLabel>
> <FieldContent>
{message.text} <Input
</div> id="current-email-password"
)} type="password"
value={form.password}
onChange={(e) =>
setForm((f) => ({ ...f, password: e.target.value }))
}
placeholder={t(
"settings.email_page.current_password_placeholder"
)}
/>
</FieldContent>
</Field>
</FieldGroup>
<div className={SETTINGS_PAGE.buttonRowSimple}> {message && (
<Button <Alert
onClick={handleSave} variant={message.type === "error" ? "destructive" : undefined}
disabled={saving} className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
size="sm" >
style={{ {message.type === "success" ? (
backgroundColor: "var(--accent)", <CheckCircle2 className="size-4" />
color: "var(--accent-fg)", ) : (
}} <AlertCircle className="size-4" />
> )}
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />} <AlertDescription>{message.text}</AlertDescription>
{t("settings.email_page.save_button")} </Alert>
</Button> )}
</div>
<div className="flex items-center justify-end">
<Button
onClick={handleSave}
disabled={saving}
size="sm"
className="min-w-28"
>
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("settings.email_page.save_button")}
</Button>
</div>
</CardContent>
</Card>
</div> </div>
); )
} }

View File

@ -1,336 +1,353 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react"
import { getMyProfile, updateMyProfile, uploadAvatar } from "@/client/api"; import { getMyProfile, updateMyProfile, uploadAvatar } from "@/client/api"
import type { ProfileResponse, UpdateProfileParams } from "@/client/model"; import type { ProfileResponse, UpdateProfileParams } from "@/client/model"
import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"
import { Input } from "@/components/ui/input"; import {
import { Label } from "@/components/ui/label"; Card,
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; CardContent,
import { Loader2, Upload, Trash2 } from "lucide-react"; CardDescription,
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"; CardHeader,
import { SETTINGS_PAGE } from "@/css/app/styles"; CardTitle,
import { t } from "@/i18n/T"; } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Loader2, Upload, Trash2 } from "lucide-react"
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
import { SETTINGS_PAGE } from "@/css/app/styles"
import { t } from "@/i18n/T"
interface UpdateProfileRequest extends UpdateProfileParams { interface UpdateProfileRequest extends UpdateProfileParams {
display_name?: string | null; display_name?: string | null
} }
export function MyAccountPage() { export function MyAccountPage() {
const { profile: cachedProfile, setProfile: setCachedProfile } = useSettingsDataCache(); const { profile: cachedProfile, setProfile: setCachedProfile } =
const [profile, setProfile] = useState<ProfileResponse | null>(cachedProfile); useSettingsDataCache()
const [loading, setLoading] = useState(!cachedProfile); const [profile, setProfile] = useState<ProfileResponse | null>(cachedProfile)
const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(!cachedProfile)
const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null); const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
display_name: cachedProfile?.display_name ?? "", display_name: cachedProfile?.display_name ?? "",
avatar_url: cachedProfile?.avatar_url ?? "", avatar_url: cachedProfile?.avatar_url ?? "",
website_url: cachedProfile?.website_url ?? "", website_url: cachedProfile?.website_url ?? "",
organization: cachedProfile?.organization ?? "", organization: cachedProfile?.organization ?? "",
}); })
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
useEffect(() => { useEffect(() => {
if (cachedProfile) return; if (cachedProfile) return
(async () => { ;(async () => {
try { try {
const res = await getMyProfile(); const res = await getMyProfile()
const d = res.data.data!; const d = res.data.data!
setProfile(d); setProfile(d)
setCachedProfile(d); setCachedProfile(d)
setForm({ setForm({
display_name: d.display_name ?? "", display_name: d.display_name ?? "",
avatar_url: d.avatar_url ?? "", avatar_url: d.avatar_url ?? "",
website_url: d.website_url ?? "", website_url: d.website_url ?? "",
organization: d.organization ?? "", organization: d.organization ?? "",
}); })
} catch { } catch {
setMessage({ type: "error", text: t("settings.my_account.load_failed") }); setMessage({
type: "error",
text: t("settings.my_account.load_failed"),
})
} finally { } finally {
setLoading(false); setLoading(false)
} }
})(); })()
}, [cachedProfile, setCachedProfile]); }, [cachedProfile, setCachedProfile])
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true); setSaving(true)
setMessage(null); setMessage(null)
await updateMyProfile({ await updateMyProfile({
display_name: form.display_name || null, display_name: form.display_name || null,
avatar_url: form.avatar_url || null, avatar_url: form.avatar_url || null,
website_url: form.website_url || null, website_url: form.website_url || null,
organization: form.organization || null, organization: form.organization || null,
} as UpdateProfileRequest); } as UpdateProfileRequest)
setMessage({ type: "success", text: t("settings.my_account.save_success") }); const nextProfile = {
await loadProfile(); ...(profile ?? cachedProfile ?? { username: "" }),
display_name: form.display_name || null,
avatar_url: form.avatar_url || null,
website_url: form.website_url || null,
organization: form.organization || null,
} as ProfileResponse
setProfile(nextProfile)
setCachedProfile(nextProfile)
setMessage({
type: "success",
text: t("settings.my_account.save_success"),
})
} catch { } catch {
setMessage({ type: "error", text: t("settings.my_account.save_failed") }); setMessage({ type: "error", text: t("settings.my_account.save_failed") })
} finally { } finally {
setSaving(false); setSaving(false)
} }
}; }
const loadProfile = async () => {
try {
setLoading(true);
if (cachedProfile) return;
const res = await getMyProfile();
const d = res.data.data!;
setProfile(d);
setCachedProfile(d);
setForm({
display_name: d.display_name ?? "",
avatar_url: d.avatar_url ?? "",
website_url: d.website_url ?? "",
organization: d.organization ?? "",
});
} catch {
setMessage({ type: "error", text: t("settings.my_account.load_failed") });
} finally {
setLoading(false);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0]
if (!file) return; if (!file) return
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
setMessage({ type: "error", text: t("settings.my_account.avatar_size_error") }); setMessage({
return; type: "error",
text: t("settings.my_account.avatar_size_error"),
})
return
} }
try { try {
setUploading(true); setUploading(true)
setMessage(null); setMessage(null)
const formData = new FormData(); const formData = new FormData()
formData.append("file", file); formData.append("file", file)
const res = await uploadAvatar(formData); const res = await uploadAvatar(formData)
const newAvatarUrl = res.data.data?.avatar_url; const newAvatarUrl = res.data.data?.avatar_url
if (newAvatarUrl) { if (newAvatarUrl) {
setForm(f => ({ ...f, avatar_url: newAvatarUrl })); setForm((f) => ({ ...f, avatar_url: newAvatarUrl }))
setMessage({ type: "success", text: t("settings.my_account.avatar_upload_success") }); setMessage({
type: "success",
text: t("settings.my_account.avatar_upload_success"),
})
} }
} catch { } catch {
setMessage({ type: "error", text: t("settings.my_account.avatar_upload_failed") }); setMessage({
type: "error",
text: t("settings.my_account.avatar_upload_failed"),
})
} finally { } finally {
setUploading(false); setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = ""
} }
}; }
const removeAvatar = () => { const removeAvatar = () => {
setForm(f => ({ ...f, avatar_url: "" })); setForm((f) => ({ ...f, avatar_url: "" }))
}; }
if (loading) { if (loading) {
return ( return (
<div className={SETTINGS_PAGE.loadingState}> <div className={SETTINGS_PAGE.loadingState}>
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--text-muted)" }} /> <Loader2
className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div> </div>
); )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<h1 <div className="space-y-2">
className={SETTINGS_PAGE.pageHeader} <h1
style={{ color: "var(--text-primary)" }} className={SETTINGS_PAGE.pageHeader}
> style={{ color: "var(--text-primary)" }}
{t("settings.my_account.title")} >
</h1> {t("settings.my_account.title")}
<p </h1>
className={SETTINGS_PAGE.pageSubtitle} <p
style={{ color: "var(--text-muted)" }} className={SETTINGS_PAGE.pageSubtitle}
>
{t("settings.my_account.subtitle")}
</p>
{/* Avatar Section */}
<div className={SETTINGS_PAGE.avatarSection}>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
> >
{t("settings.my_account.avatar")} {t("settings.my_account.subtitle")}
</Label> </p>
<div className={SETTINGS_PAGE.avatarRow}> </div>
<Avatar className="w-20 h-20 rounded-lg border-2" style={{ borderColor: "var(--border-default)" }}>
<AvatarImage
src={form.avatar_url || undefined}
alt={profile?.username}
/>
<AvatarFallback
className="text-2xl rounded-lg"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
>
{profile?.username?.[0]?.toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2"> <Card>
<div className="flex items-center gap-2"> <CardHeader className="gap-2">
<CardTitle>{t("settings.my_account.avatar")}</CardTitle>
<CardDescription>
{t("settings.my_account.avatar_hint")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<Avatar className="size-20 rounded-2xl ring-1 ring-[var(--border-subtle)]">
<AvatarImage
src={form.avatar_url || undefined}
alt={profile?.username}
/>
<AvatarFallback
className="rounded-2xl text-2xl"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
>
{profile?.username?.[0]?.toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8" onClick={() => fileInputRef.current?.click()}
onClick={() => fileInputRef.current?.click()} disabled={uploading}
disabled={uploading}
> >
{uploading ? <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-2" />} {uploading ? (
{t("settings.my_account.upload_avatar")} <Loader2
data-icon="inline-start"
className="animate-spin"
/>
) : (
<Upload data-icon="inline-start" />
)}
{t("settings.my_account.upload_avatar")}
</Button> </Button>
{form.avatar_url && ( {form.avatar_url && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={removeAvatar} onClick={removeAvatar}
> >
<Trash2 className="w-3.5 h-3.5 mr-2" /> <Trash2 data-icon="inline-start" />
{t("settings.my_account.remove")} {t("settings.my_account.remove")}
</Button> </Button>
)} )}
</div> </div>
<p className={SETTINGS_PAGE.avatarHint}> <input
{t("settings.my_account.avatar_hint")}
</p>
<input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
accept="image/*" accept="image/*"
className="hidden" className="hidden"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-2">
<CardTitle>Profile details</CardTitle>
<CardDescription>{t("settings.my_account.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="grid gap-5">
<div className="grid gap-2">
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.my_account.username")}
</Label>
<Input
value={profile?.username ?? ""}
disabled
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-muted)",
}}
/> />
</div> </div>
</div>
</div>
{/* Form Fields */} <div className="grid gap-2">
<div className={SETTINGS_PAGE.formSection}> <Label
<div className={SETTINGS_PAGE.formGroup}> className={SETTINGS_PAGE.formLabel}
<Label style={{ color: "var(--text-muted)" }}
className={SETTINGS_PAGE.formLabel} >
style={{ color: "var(--text-muted)" }} {t("settings.my_account.display_name")}
> </Label>
{t("settings.my_account.username")} <Input
</Label> value={form.display_name}
<Input onChange={(e) =>
value={profile?.username ?? ""} setForm((f) => ({ ...f, display_name: e.target.value }))
disabled }
className={SETTINGS_PAGE.formInput} placeholder={t("settings.my_account.display_name_placeholder")}
style={{ className={SETTINGS_PAGE.formInput}
backgroundColor: "var(--surface-elevated)", style={{
borderColor: "var(--border-default)", backgroundColor: "var(--surface-elevated)",
color: "var(--text-muted)", borderColor: "var(--border-default)",
}} color: "var(--text-primary)",
/> }}
</div> />
</div>
<div className={SETTINGS_PAGE.formGroup}> <div className="grid gap-2">
<Label <Label
className={SETTINGS_PAGE.formLabel} className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
> >
{t("settings.my_account.display_name")} {t("settings.my_account.website")}
</Label> </Label>
<Input <Input
value={form.display_name} value={form.website_url}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, display_name: e.target.value })) setForm((f) => ({ ...f, website_url: e.target.value }))
} }
placeholder={t("settings.my_account.display_name_placeholder")} placeholder={t("settings.my_account.website_placeholder")}
className={SETTINGS_PAGE.formInput} className={SETTINGS_PAGE.formInput}
style={{ style={{
backgroundColor: "var(--surface-elevated)", backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)", borderColor: "var(--border-default)",
color: "var(--text-primary)", color: "var(--text-primary)",
}} }}
/> />
</div> </div>
<div className={SETTINGS_PAGE.formGroup}> <div className="grid gap-2">
<Label <Label
className={SETTINGS_PAGE.formLabel} className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
> >
{t("settings.my_account.website")} {t("settings.my_account.organization")}
</Label> </Label>
<Input <Input
value={form.website_url} value={form.organization}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, website_url: e.target.value })) setForm((f) => ({ ...f, organization: e.target.value }))
} }
placeholder={t("settings.my_account.website_placeholder")} placeholder={t("settings.my_account.org_placeholder")}
className={SETTINGS_PAGE.formInput} className={SETTINGS_PAGE.formInput}
style={{ style={{
backgroundColor: "var(--surface-elevated)", backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)", borderColor: "var(--border-default)",
color: "var(--text-primary)", color: "var(--text-primary)",
}} }}
/> />
</div> </div>
</CardContent>
</Card>
<div className={SETTINGS_PAGE.formGroup}>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.my_account.organization")}
</Label>
<Input
value={form.organization}
onChange={(e) =>
setForm((f) => ({ ...f, organization: e.target.value }))
}
placeholder={t("settings.my_account.org_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
</div>
{/* Message */}
{message && ( {message && (
<div <Alert
className={SETTINGS_PAGE.message} variant={message.type === "error" ? "destructive" : "default"}
style={{ className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
color:
message.type === "success"
? "var(--success)"
: "var(--destructive)",
}}
> >
{message.text} <AlertDescription>{message.text}</AlertDescription>
</div> </Alert>
)} )}
{/* Save Button */} <div className="flex justify-end">
<div className={SETTINGS_PAGE.buttonRow}>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
size="sm" size="sm"
style={{ className="min-w-28"
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
> >
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />} {saving && (
<Loader2 data-icon="inline-start" className="animate-spin" />
)}
{t("settings.my_account.save_changes")} {t("settings.my_account.save_changes")}
</Button> </Button>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,61 +1,89 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { Bell, Loader2, Settings2, Sparkles, ShieldAlert } from "lucide-react"
import { import {
getNotificationPreferences, getNotificationPreferences,
updateNotificationPreferences, updateNotificationPreferences,
} from "@/client/api"; } from "@/client/api"
import type { NotificationPreferencesResponse } from "@/client/model"; import type { NotificationPreferencesResponse } from "@/client/model"
import { Button } from "@/components/ui/button"; import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"
import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select"
import { Loader2 } from "lucide-react"; import { Separator } from "@/components/ui/separator"
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"; import { Switch } from "@/components/ui/switch"
import { NOTIFICATIONS_PAGE } from "@/css/app/styles"; import { t } from "@/i18n/T"
import { t } from "@/i18n/T";
const DIGEST_MODES = [ const DIGEST_MODES = [
{ value: "instant", label: t("settings.notifications_page.instant") }, { value: "instant", label: t("settings.notifications_page.instant") },
{ value: "daily", label: t("settings.notifications_page.daily_digest") }, { value: "daily", label: t("settings.notifications_page.daily_digest") },
{ value: "weekly", label: t("settings.notifications_page.weekly_digest") }, { value: "weekly", label: t("settings.notifications_page.weekly_digest") },
{ value: "off", label: t("settings.notifications_page.off") }, { value: "off", label: t("settings.notifications_page.off") },
]; ]
const ToggleRow = ({ function ToggleRow({
label, label,
desc, desc,
checked, checked,
onChange, onChange,
}: { }: {
label: string; label: string
desc: string; desc: string
checked: boolean; checked: boolean
onChange: (v: boolean) => void; onChange: (v: boolean) => void
}) => ( }) {
<div className={NOTIFICATIONS_PAGE.toggleRow}> return (
<div className="flex-1 pr-4"> <div className="flex items-start justify-between gap-4 py-4">
<p className={NOTIFICATIONS_PAGE.toggleLabel} style={{ color: "var(--text-primary)" }}> <div className="min-w-0">
{label} <p
</p> className="text-[14px] font-medium"
<p className={NOTIFICATIONS_PAGE.toggleLabelDesc} style={{ color: "var(--text-muted)" }}> style={{ color: "var(--text-primary)" }}
{desc} >
</p> {label}
</p>
<p
className="mt-0.5 text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{desc}
</p>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div> </div>
<Switch checked={checked} onCheckedChange={onChange} /> )
</div> }
);
export function NotificationsPage() { export function NotificationsPage() {
const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache(); const {
const [, setPrefs] = notificationPrefs: cachedPrefs,
useState<NotificationPreferencesResponse | null>(cachedPrefs); setNotificationPrefs: setCachedPrefs,
const [loading, setLoading] = useState(!cachedPrefs); } = useSettingsDataCache()
const [saving, setSaving] = useState(false); const [, setPrefs] = useState<NotificationPreferencesResponse | null>(
cachedPrefs
)
const [loading, setLoading] = useState(!cachedPrefs)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ const [form, setForm] = useState({
email_enabled: cachedPrefs?.email_enabled ?? true, email_enabled: cachedPrefs?.email_enabled ?? true,
in_app_enabled: cachedPrefs?.in_app_enabled ?? true, in_app_enabled: cachedPrefs?.in_app_enabled ?? true,
@ -65,42 +93,45 @@ export function NotificationsPage() {
marketing_enabled: cachedPrefs?.marketing_enabled ?? false, marketing_enabled: cachedPrefs?.marketing_enabled ?? false,
security_enabled: cachedPrefs?.security_enabled ?? true, security_enabled: cachedPrefs?.security_enabled ?? true,
product_enabled: cachedPrefs?.product_enabled ?? true, product_enabled: cachedPrefs?.product_enabled ?? true,
}); })
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
useEffect(() => { useEffect(() => {
if (cachedPrefs) return; if (cachedPrefs) return
(async () => { ;(async () => {
try { try {
const res = await getNotificationPreferences(); const res = await getNotificationPreferences()
const d = res.data.data!; const data = res.data.data!
setPrefs(d); setPrefs(data)
setCachedPrefs(d); setCachedPrefs(data)
setForm({ setForm({
email_enabled: d.email_enabled, email_enabled: data.email_enabled,
in_app_enabled: d.in_app_enabled, in_app_enabled: data.in_app_enabled,
push_enabled: d.push_enabled, push_enabled: data.push_enabled,
digest_mode: d.digest_mode, digest_mode: data.digest_mode,
dnd_enabled: d.dnd_enabled, dnd_enabled: data.dnd_enabled,
marketing_enabled: d.marketing_enabled, marketing_enabled: data.marketing_enabled,
security_enabled: d.security_enabled, security_enabled: data.security_enabled,
product_enabled: d.product_enabled, product_enabled: data.product_enabled,
}); })
} catch { } catch {
setMessage({ type: "error", text: t("settings.notifications_page.load_failed") }); setMessage({
type: "error",
text: t("settings.notifications_page.load_failed"),
})
} finally { } finally {
setLoading(false); setLoading(false)
} }
})(); })()
}, [cachedPrefs, setCachedPrefs]); }, [cachedPrefs, setCachedPrefs])
const handleSave = async () => { const handleSave = async () => {
try { try {
setSaving(true); setSaving(true)
setMessage(null); setMessage(null)
await updateNotificationPreferences({ await updateNotificationPreferences({
email_enabled: form.email_enabled, email_enabled: form.email_enabled,
in_app_enabled: form.in_app_enabled, in_app_enabled: form.in_app_enabled,
@ -110,228 +141,202 @@ export function NotificationsPage() {
marketing_enabled: form.marketing_enabled, marketing_enabled: form.marketing_enabled,
security_enabled: form.security_enabled, security_enabled: form.security_enabled,
product_enabled: form.product_enabled, product_enabled: form.product_enabled,
}); })
setMessage({ type: "success", text: t("settings.notifications_page.save_success") }); setMessage({
type: "success",
text: t("settings.notifications_page.save_success"),
})
} catch { } catch {
setMessage({ type: "error", text: t("settings.notifications_page.save_failed") }); setMessage({
type: "error",
text: t("settings.notifications_page.save_failed"),
})
} finally { } finally {
setSaving(false); setSaving(false)
} }
}; }
if (loading) { if (loading) {
return ( return (
<div className={NOTIFICATIONS_PAGE.loadingState}> <div className="flex items-center justify-center py-20">
<Loader2 <Loader2
className="w-6 h-6 animate-spin" className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
/> />
</div> </div>
); )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<h1 <div className="flex flex-col gap-2">
className={NOTIFICATIONS_PAGE.pageHeader} <h1
style={{ color: "var(--text-primary)" }} className="text-[20px] font-bold"
> style={{ color: "var(--text-primary)" }}
{t("settings.notifications_page.title")} >
</h1> {t("settings.notifications_page.title")}
<p </h1>
className={NOTIFICATIONS_PAGE.pageSubtitle} <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
style={{ color: "var(--text-muted)" }} {t("settings.notifications_page.subtitle")}
> </p>
{t("settings.notifications_page.subtitle")}
</p>
<div className={NOTIFICATIONS_PAGE.sectionGroup}>
{/* Notification Channels */}
<div>
<h3
className={NOTIFICATIONS_PAGE.sectionTitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.notifications_page.channels")}
</h3>
<div
className={NOTIFICATIONS_PAGE.toggleContainer}
style={{ border: "1px solid var(--border-subtle)" }}
>
<ToggleRow
label={t("settings.notifications_page.email_notifications")}
desc={t("settings.notifications_page.email_notifications_desc")}
checked={form.email_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, email_enabled: v }))
}
/>
<div
className={NOTIFICATIONS_PAGE.toggleDivider}
style={{ borderTop: "1px solid var(--border-subtle)" }}
/>
<ToggleRow
label={t("settings.notifications_page.in_app_notifications")}
desc={t("settings.notifications_page.in_app_notifications_desc")}
checked={form.in_app_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, in_app_enabled: v }))
}
/>
<div
className={NOTIFICATIONS_PAGE.toggleDivider}
style={{ borderTop: "1px solid var(--border-subtle)" }}
/>
<ToggleRow
label={t("settings.notifications_page.push_notifications")}
desc={t("settings.notifications_page.push_notifications_desc")}
checked={form.push_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, push_enabled: v }))
}
/>
</div>
</div>
{/* Digest Mode */}
<div>
<Label
className={NOTIFICATIONS_PAGE.sectionTitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.notifications_page.digest_mode")}
</Label>
<Select
value={form.digest_mode}
onValueChange={(v) =>
setForm((f) => ({ ...f, digest_mode: v }))
}
>
<SelectTrigger
className={NOTIFICATIONS_PAGE.selectTrigger}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
>
<SelectValue />
</SelectTrigger>
<SelectContent
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
>
{DIGEST_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Notification Types */}
<div>
<h3
className={NOTIFICATIONS_PAGE.sectionTitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.notifications_page.notification_types")}
</h3>
<div
className={NOTIFICATIONS_PAGE.toggleContainer}
style={{ border: "1px solid var(--border-subtle)" }}
>
<ToggleRow
label={t("settings.notifications_page.security_notifications")}
desc={t("settings.notifications_page.security_notifications_desc")}
checked={form.security_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, security_enabled: v }))
}
/>
<div
className={NOTIFICATIONS_PAGE.toggleDivider}
style={{ borderTop: "1px solid var(--border-subtle)" }}
/>
<ToggleRow
label={t("settings.notifications_page.product_updates")}
desc={t("settings.notifications_page.product_updates_desc")}
checked={form.product_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, product_enabled: v }))
}
/>
<div
className={NOTIFICATIONS_PAGE.toggleDivider}
style={{ borderTop: "1px solid var(--border-subtle)" }}
/>
<ToggleRow
label={t("settings.notifications_page.marketing_emails")}
desc={t("settings.notifications_page.marketing_emails_desc")}
checked={form.marketing_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, marketing_enabled: v }))
}
/>
</div>
</div>
{/* DND */}
<div>
<h3
className={NOTIFICATIONS_PAGE.sectionTitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.notifications_page.do_not_disturb")}
</h3>
<div
className={NOTIFICATIONS_PAGE.toggleContainer}
style={{ border: "1px solid var(--border-subtle)" }}
>
<ToggleRow
label={t("settings.notifications_page.enable_dnd")}
desc={t("settings.notifications_page.enable_dnd_desc")}
checked={form.dnd_enabled}
onChange={(v) =>
setForm((f) => ({ ...f, dnd_enabled: v }))
}
/>
</div>
</div>
</div> </div>
<Card>
<CardHeader className="gap-2">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Bell className="size-4" />
{t("settings.notifications_page.channels")}
</CardTitle>
<CardDescription>
Route notifications to the right place for your workflow.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col pb-2">
<ToggleRow
label={t("settings.notifications_page.email_notifications")}
desc={t("settings.notifications_page.email_notifications_desc")}
checked={form.email_enabled}
onChange={(v) => setForm((f) => ({ ...f, email_enabled: v }))}
/>
<Separator />
<ToggleRow
label={t("settings.notifications_page.in_app_notifications")}
desc={t("settings.notifications_page.in_app_notifications_desc")}
checked={form.in_app_enabled}
onChange={(v) => setForm((f) => ({ ...f, in_app_enabled: v }))}
/>
<Separator />
<ToggleRow
label={t("settings.notifications_page.push_notifications")}
desc={t("settings.notifications_page.push_notifications_desc")}
checked={form.push_enabled}
onChange={(v) => setForm((f) => ({ ...f, push_enabled: v }))}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-2">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Sparkles className="size-4" />
{t("settings.notifications_page.digest_mode")}
</CardTitle>
<CardDescription>
{t("settings.notifications_page.digest_mode")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="digest-mode">
<FieldTitle>
{t("settings.notifications_page.digest_mode")}
</FieldTitle>
<FieldDescription>
Pick how often digest notifications should arrive.
</FieldDescription>
</FieldLabel>
<FieldContent>
<Select
value={form.digest_mode}
onValueChange={(v) =>
setForm((f) => ({ ...f, digest_mode: v }))
}
>
<SelectTrigger id="digest-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIGEST_MODES.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-2">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Settings2 className="size-4" />
{t("settings.notifications_page.notification_types")}
</CardTitle>
<CardDescription>
Filter the product and security events you care about.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col pb-2">
<ToggleRow
label={t("settings.notifications_page.security_notifications")}
desc={t("settings.notifications_page.security_notifications_desc")}
checked={form.security_enabled}
onChange={(v) => setForm((f) => ({ ...f, security_enabled: v }))}
/>
<Separator />
<ToggleRow
label={t("settings.notifications_page.product_updates")}
desc={t("settings.notifications_page.product_updates_desc")}
checked={form.product_enabled}
onChange={(v) => setForm((f) => ({ ...f, product_enabled: v }))}
/>
<Separator />
<ToggleRow
label={t("settings.notifications_page.marketing_emails")}
desc={t("settings.notifications_page.marketing_emails_desc")}
checked={form.marketing_enabled}
onChange={(v) => setForm((f) => ({ ...f, marketing_enabled: v }))}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-2">
<CardTitle className="flex items-center gap-2 text-[15px]">
<ShieldAlert className="size-4" />
{t("settings.notifications_page.do_not_disturb")}
</CardTitle>
<CardDescription>
Silence notification delivery during focused time.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col pb-2">
<ToggleRow
label={t("settings.notifications_page.enable_dnd")}
desc={t("settings.notifications_page.enable_dnd_desc")}
checked={form.dnd_enabled}
onChange={(v) => setForm((f) => ({ ...f, dnd_enabled: v }))}
/>
</CardContent>
</Card>
{message && ( {message && (
<div <Alert
className={NOTIFICATIONS_PAGE.message} variant={message.type === "error" ? "destructive" : undefined}
style={{ className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
color:
message.type === "success"
? "var(--success)"
: "var(--destructive)",
}}
> >
{message.text} <AlertDescription>{message.text}</AlertDescription>
</div> </Alert>
)} )}
<div className={NOTIFICATIONS_PAGE.buttonRow}> <div className="flex items-center justify-end gap-3">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[11px]">
{form.push_enabled ? "Push on" : "Push off"}
</Badge>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
size="sm" size="sm"
style={{ className="min-w-28"
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
> >
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />} {saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("settings.notifications_page.save_button")} {t("settings.notifications_page.save_button")}
</Button> </Button>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,166 +1,197 @@
import { useState } from "react"; import { useState } from "react"
import { apiUserChangePassword } from "@/client/api"; import { AlertCircle, CheckCircle2, Loader2, ShieldCheck } from "lucide-react"
import { Button } from "@/components/ui/button"; import { apiUserChangePassword } from "@/client/api"
import { Input } from "@/components/ui/input"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"
import { Loader2 } from "lucide-react"; import {
import { SETTINGS_PAGE } from "@/css/app/styles"; Card,
import { t } from "@/i18n/T"; CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { t } from "@/i18n/T"
export function PasswordPage() { export function PasswordPage() {
const [form, setForm] = useState({ const [form, setForm] = useState({
old_password: "", old_password: "",
new_password: "", new_password: "",
confirm_password: "", confirm_password: "",
}); })
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
const handleSave = async () => { const handleSave = async () => {
if (form.new_password !== form.confirm_password) { if (form.new_password !== form.confirm_password) {
setMessage({ type: "error", text: t("settings.password.mismatch") }); setMessage({ type: "error", text: t("settings.password.mismatch") })
return; return
} }
if (form.new_password.length < 8) { if (form.new_password.length < 8) {
setMessage({ type: "error", text: t("settings.password.min_length") }); setMessage({ type: "error", text: t("settings.password.min_length") })
return; return
} }
try { try {
setSaving(true); setSaving(true)
setMessage(null); setMessage(null)
await apiUserChangePassword({ await apiUserChangePassword({
old_password: form.old_password, old_password: form.old_password,
new_password: form.new_password, new_password: form.new_password,
}); })
setMessage({ type: "success", text: t("settings.password.change_success") }); setMessage({
setForm({ old_password: "", new_password: "", confirm_password: "" }); type: "success",
text: t("settings.password.change_success"),
})
setForm({ old_password: "", new_password: "", confirm_password: "" })
} catch { } catch {
setMessage({ type: "error", text: t("settings.password.change_failed") }); setMessage({ type: "error", text: t("settings.password.change_failed") })
} finally { } finally {
setSaving(false); setSaving(false)
} }
}; }
return ( return (
<div> <div className="flex flex-col gap-6">
<h1 <div className="flex flex-col gap-2">
className={SETTINGS_PAGE.pageHeader} <h1
style={{ color: "var(--text-primary)" }} className="text-[20px] font-bold"
> style={{ color: "var(--text-primary)" }}
{t("settings.password.title")} >
</h1> {t("settings.password.title")}
<p </h1>
className={SETTINGS_PAGE.pageSubtitle} <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
style={{ color: "var(--text-muted)" }} {t("settings.password.subtitle")}
> </p>
{t("settings.password.subtitle")}
</p>
<div className={SETTINGS_PAGE.formSection}>
<div className={SETTINGS_PAGE.formGroup}>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.password.current_password")}
</Label>
<Input
type="password"
value={form.old_password}
onChange={(e) =>
setForm((f) => ({ ...f, old_password: e.target.value }))
}
placeholder={t("settings.password.current_password_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
<div className={SETTINGS_PAGE.formGroup}>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.password.new_password")}
</Label>
<Input
type="password"
value={form.new_password}
onChange={(e) =>
setForm((f) => ({ ...f, new_password: e.target.value }))
}
placeholder={t("settings.password.new_password_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
<div className={SETTINGS_PAGE.formGroup}>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.password.confirm_password")}
</Label>
<Input
type="password"
value={form.confirm_password}
onChange={(e) =>
setForm((f) => ({
...f,
confirm_password: e.target.value,
}))
}
placeholder={t("settings.password.confirm_password_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
</div> </div>
{message && ( <Card>
<div <CardHeader className="gap-2">
className={SETTINGS_PAGE.message} <CardTitle className="text-[15px]">
style={{ {t("settings.password.title")}
color: </CardTitle>
message.type === "success" <CardDescription>{t("settings.password.subtitle")}</CardDescription>
? "var(--success)" </CardHeader>
: "var(--destructive)", <CardContent className="flex flex-col gap-5">
}} <Alert className="border-[var(--border-subtle)] bg-[var(--surface-ground)]">
> <ShieldCheck className="size-4" />
{message.text} <AlertTitle>{t("settings.password.title")}</AlertTitle>
</div> <AlertDescription>
)} Use a unique password that is at least 8 characters long.
</AlertDescription>
</Alert>
<div className={SETTINGS_PAGE.buttonRowSimple}> <FieldGroup>
<Button <Field>
onClick={handleSave} <FieldLabel htmlFor="current-password">
disabled={saving} <FieldTitle>
size="sm" {t("settings.password.current_password")}
style={{ </FieldTitle>
backgroundColor: "var(--accent)", <FieldDescription>
color: "var(--accent-fg)", {t("settings.password.current_password_placeholder")}
}} </FieldDescription>
> </FieldLabel>
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />} <FieldContent>
{t("settings.password.submit")} <Input
</Button> id="current-password"
</div> type="password"
value={form.old_password}
onChange={(e) =>
setForm((f) => ({ ...f, old_password: e.target.value }))
}
placeholder={t(
"settings.password.current_password_placeholder"
)}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="new-password">
<FieldTitle>{t("settings.password.new_password")}</FieldTitle>
<FieldDescription>
{t("settings.password.min_length")}
</FieldDescription>
</FieldLabel>
<FieldContent>
<Input
id="new-password"
type="password"
value={form.new_password}
onChange={(e) =>
setForm((f) => ({ ...f, new_password: e.target.value }))
}
placeholder={t("settings.password.new_password_placeholder")}
/>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="confirm-password">
<FieldTitle>
{t("settings.password.confirm_password")}
</FieldTitle>
<FieldDescription>
Re-enter the new password to confirm it.
</FieldDescription>
</FieldLabel>
<FieldContent>
<Input
id="confirm-password"
type="password"
value={form.confirm_password}
onChange={(e) =>
setForm((f) => ({
...f,
confirm_password: e.target.value,
}))
}
placeholder={t(
"settings.password.confirm_password_placeholder"
)}
/>
</FieldContent>
</Field>
</FieldGroup>
{message && (
<Alert
variant={message.type === "error" ? "destructive" : undefined}
className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
>
{message.type === "success" ? (
<CheckCircle2 className="size-4" />
) : (
<AlertCircle className="size-4" />
)}
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-end">
<Button
onClick={handleSave}
disabled={saving}
size="sm"
className="min-w-28"
>
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("settings.password.submit")}
</Button>
</div>
</CardContent>
</Card>
</div> </div>
); )
} }

View File

@ -1,94 +1,146 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { getNotificationPreferences, updateNotificationPreferences } from "@/client/api"; import {
import type { NotificationPreferencesResponse } from "@/client/model"; getNotificationPreferences,
import { Switch } from "@/components/ui/switch"; updateNotificationPreferences,
import { Label } from "@/components/ui/label"; } from "@/client/api"
import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react"; import type { NotificationPreferencesResponse } from "@/client/model"
import { t } from "@/i18n/T"; import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react"
import { t } from "@/i18n/T"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
export function PushSettingsPage() { export function PushSettingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false)
const [pushEnabled, setPushEnabled] = useState(false); const [pushEnabled, setPushEnabled] = useState(false)
const canPush = 'Notification' in window && 'serviceWorker' in navigator; const canPush = "Notification" in window && "serviceWorker" in navigator
useEffect(() => { useEffect(() => {
(async () => { ;(async () => {
try { try {
setLoading(true); setLoading(true)
const res = await getNotificationPreferences(); const res = await getNotificationPreferences()
const data = res.data.data as NotificationPreferencesResponse | undefined; const data = res.data.data as
setPushEnabled(data?.push_enabled ?? false); | NotificationPreferencesResponse
| undefined
setPushEnabled(data?.push_enabled ?? false)
} catch { } catch {
setError(t("settings.push.load_failed")); setError(t("settings.push.load_failed"))
} finally { } finally {
setLoading(false); setLoading(false)
} }
})(); })()
}, []); }, [])
const handleTogglePush = async (checked: boolean) => { const handleTogglePush = async (checked: boolean) => {
if (checked && 'Notification' in window) { if (checked && "Notification" in window) {
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission()
if (permission !== 'granted') { if (permission !== "granted") {
setError(t("settings.push.permission_denied")); setError(t("settings.push.permission_denied"))
return; return
} }
} }
try { try {
setSaving(true); setSaving(true)
setError(null); setError(null)
await updateNotificationPreferences({ await updateNotificationPreferences({
push_enabled: checked, push_enabled: checked,
} as Partial<NotificationPreferencesResponse>); } as Partial<NotificationPreferencesResponse>)
setPushEnabled(checked); setPushEnabled(checked)
setSuccess(true); setSuccess(true)
setTimeout(() => setSuccess(false), 3000); setTimeout(() => setSuccess(false), 3000)
} catch { } catch {
setError(t("settings.push.update_failed")); setError(t("settings.push.update_failed"))
} finally { } finally {
setSaving(false); setSaving(false)
} }
}; }
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--text-muted)" }} /> <Loader2
className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div> </div>
); )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<h1 className="text-[20px] font-bold mb-1" style={{ color: "var(--text-primary)" }}>{t("settings.push.title")}</h1> <div className="space-y-2">
<p className="text-[13px] mb-6" style={{ color: "var(--text-muted)" }}> <h1
{t("settings.push.subtitle")} className="text-[20px] font-bold"
</p> style={{ color: "var(--text-primary)" }}
>
{t("settings.push.title")}
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
{t("settings.push.subtitle")}
</p>
</div>
{!canPush && ( {!canPush && (
<div className="mb-6 p-4 rounded-lg flex items-start gap-3" style={{ backgroundColor: "var(--warning-alpha10)", border: "1px solid var(--warning)" }}> <Alert
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" style={{ color: "var(--warning)" }} /> variant="destructive"
<div className="text-sm" style={{ color: "var(--warning)" }}> className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
<p className="font-semibold mb-1">{t("settings.push.not_supported")}</p> >
<p>{t("settings.push.not_supported_desc")}</p> <AlertCircle className="size-4" />
</div> <AlertDescription>
</div> {t("settings.push.not_supported_desc")}
</AlertDescription>
</Alert>
)} )}
<div className="space-y-6"> <Card>
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}> <CardHeader className="gap-2">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Smartphone className="size-4" />
{t("settings.push.enable")}
<Badge
variant={pushEnabled ? "secondary" : "outline"}
className="rounded-full px-2 py-0.5 text-[11px]"
>
{pushEnabled ? "On" : "Off"}
</Badge>
</CardTitle>
<CardDescription>{t("settings.push.enable_desc")}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: "var(--accent-bg)" }}> <div className="flex size-10 items-center justify-center rounded-full bg-[var(--accent-bg)]">
<Smartphone className="w-5 h-5" style={{ color: "var(--accent)" }} /> <Smartphone
className="size-5"
style={{ color: "var(--accent)" }}
/>
</div> </div>
<div> <div className="space-y-0.5">
<Label className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>{t("settings.push.enable")}</Label> <Label
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{t("settings.push.enable_desc")}</p> className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{t("settings.push.enable")}
</Label>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{pushEnabled
? t("settings.push.saved")
: t("settings.push.subtitle")}
</p>
</div> </div>
</div> </div>
<Switch <Switch
@ -96,43 +148,58 @@ export function PushSettingsPage() {
onCheckedChange={handleTogglePush} onCheckedChange={handleTogglePush}
disabled={saving || !canPush} disabled={saving || !canPush}
/> />
</div> </CardContent>
</Card>
{pushEnabled && ( {pushEnabled && (
<div className="p-4 rounded-lg border space-y-4" style={{ borderColor: "var(--border-default)" }}> <Card>
<h3 className="text-xs font-semibold uppercase" style={{ color: "var(--text-muted)" }}>{t("settings.push.filters_title")}</h3> <CardHeader className="gap-2">
<div className="flex items-center justify-between text-sm"> <CardTitle className="text-[15px]">
<span style={{ color: "var(--text-primary)" }}>{t("settings.push.mentions")}</span> {t("settings.push.filters_title")}
<Switch checked={true} disabled /> </CardTitle>
</div> <CardDescription>{t("settings.push.coming_soon")}</CardDescription>
<div className="flex items-center justify-between text-sm"> </CardHeader>
<span style={{ color: "var(--text-primary)" }}>{t("settings.push.new_issues")}</span> <CardContent className="grid gap-4">
<Switch checked={true} disabled /> <div className="flex items-center justify-between text-sm">
</div> <span style={{ color: "var(--text-primary)" }}>
<div className="flex items-center justify-between text-sm"> {t("settings.push.mentions")}
<span style={{ color: "var(--text-primary)" }}>{t("settings.push.system_updates")}</span> </span>
<Switch checked={false} disabled /> <Switch checked={true} disabled />
</div>
<p className="text-[10px] italic" style={{ color: "var(--text-muted)" }}>
{t("settings.push.coming_soon")}
</p>
</div> </div>
)} <Separator />
</div> <div className="flex items-center justify-between text-sm">
<span style={{ color: "var(--text-primary)" }}>
{t("settings.push.new_issues")}
</span>
<Switch checked={true} disabled />
</div>
<Separator />
<div className="flex items-center justify-between text-sm">
<span style={{ color: "var(--text-primary)" }}>
{t("settings.push.system_updates")}
</span>
<Switch checked={false} disabled />
</div>
</CardContent>
</Card>
)}
{error && ( {error && (
<div className="mt-4 p-3 rounded text-xs flex items-center gap-2" style={{ backgroundColor: "var(--destructive-alpha10)", border: "1px solid var(--destructive)", color: "var(--destructive)" }}> <Alert
<AlertCircle className="w-4 h-4" /> variant="destructive"
{error} className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
</div> >
<AlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)} )}
{success && ( {success && (
<div className="mt-4 p-3 rounded text-xs flex items-center gap-2" style={{ backgroundColor: "var(--success-alpha10)", border: "1px solid var(--success)", color: "var(--success)" }}> <Alert className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<ShieldCheck className="w-4 h-4" /> <ShieldCheck className="size-4" />
{t("settings.push.saved")} <AlertDescription>{t("settings.push.saved")}</AlertDescription>
</div> </Alert>
)} )}
</div> </div>
); )
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react"
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"
import { import {
User, User,
Palette, Palette,
@ -11,82 +11,111 @@ import {
Smartphone, Smartphone,
Key as KeyIcon, Key as KeyIcon,
CreditCard, CreditCard,
} from "lucide-react"; } from "lucide-react"
import { t } from "@/i18n/T"; import { t } from "@/i18n/T"
import { SETTINGS_LAYOUT } from "@/css/settings/styles";
const NAV_SECTIONS = [ const NAV_SECTIONS = [
{ {
label: t("settings.settings_nav.user_settings"), label: t("settings.settings_nav.user_settings"),
items: [ items: [
{ to: "/me/settings", end: true, icon: User, label: t("settings.settings_nav.my_account") }, {
{ to: "/me/settings/billing", icon: CreditCard, label: t("settings.settings_nav.billing") }, to: "/me/settings",
{ to: "/me/settings/appearance", icon: Palette, label: t("settings.settings_nav.appearance") }, end: true,
{ to: "/me/settings/notifications", icon: Bell, label: t("settings.settings_nav.notifications") }, icon: User,
{ to: "/me/settings/push", icon: Smartphone, label: t("settings.settings_nav.push_settings") }, label: t("settings.settings_nav.my_account"),
},
{
to: "/me/settings/billing",
icon: CreditCard,
label: t("settings.settings_nav.billing"),
},
{
to: "/me/settings/appearance",
icon: Palette,
label: t("settings.settings_nav.appearance"),
},
{
to: "/me/settings/notifications",
icon: Bell,
label: t("settings.settings_nav.notifications"),
},
{
to: "/me/settings/push",
icon: Smartphone,
label: t("settings.settings_nav.push_settings"),
},
], ],
}, },
{ {
label: t("settings.settings_nav.security"), label: t("settings.settings_nav.security"),
items: [ items: [
{ to: "/me/settings/password", icon: Shield, label: t("settings.settings_nav.change_password") }, {
{ to: "/me/settings/email", icon: Mail, label: t("settings.settings_nav.email") }, to: "/me/settings/password",
{ to: "/me/settings/ssh-keys", icon: Key, label: t("settings.settings_nav.ssh_keys") }, icon: Shield,
{ to: "/me/settings/access-keys", icon: KeyIcon, label: t("settings.settings_nav.access_tokens") }, label: t("settings.settings_nav.change_password"),
},
{
to: "/me/settings/email",
icon: Mail,
label: t("settings.settings_nav.email"),
},
{
to: "/me/settings/ssh-keys",
icon: Key,
label: t("settings.settings_nav.ssh_keys"),
},
{
to: "/me/settings/access-keys",
icon: KeyIcon,
label: t("settings.settings_nav.access_tokens"),
},
], ],
}, },
]; ]
const SETTINGS_RETURN_PATH_KEY = "settings_return_path"; const SETTINGS_RETURN_PATH_KEY = "settings_return_path"
export function SettingsLayout() { export function SettingsLayout() {
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null)
const location = useLocation(); const location = useLocation()
const navigate = useNavigate(); const navigate = useNavigate()
const currentSection = NAV_SECTIONS.flatMap((section) => section.items).find(
(item) => {
if (item.end) {
return location.pathname === item.to
}
return location.pathname.startsWith(item.to)
}
)
useEffect(() => { useEffect(() => {
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTop = 0; contentRef.current.scrollTop = 0
} }
}, [location.pathname]); }, [location.pathname])
const handleClose = () => { const handleClose = () => {
const returnPath = localStorage.getItem(SETTINGS_RETURN_PATH_KEY); const returnPath = localStorage.getItem(SETTINGS_RETURN_PATH_KEY)
localStorage.removeItem(SETTINGS_RETURN_PATH_KEY); localStorage.removeItem(SETTINGS_RETURN_PATH_KEY)
navigate(returnPath || "/me"); navigate(returnPath || "/me")
}; }
return ( return (
<div <div className="flex h-screen overflow-hidden bg-[var(--surface-ground)]">
className={SETTINGS_LAYOUT.container} <nav className="flex w-[250px] shrink-0 flex-col overflow-y-auto border-r border-[var(--border-subtle)] bg-[var(--surface-rail)]/90 backdrop-blur-xl">
style={{ <div className="flex flex-col gap-1 px-4 pt-6 pb-4">
backgroundColor: "var(--surface-ground)", <h2 className="text-[11px] font-semibold tracking-[0.24em] text-[var(--text-tertiary)] uppercase">
}} {t("settings.settings_nav.user_settings")}
> </h2>
<nav <p className="text-[13px] font-medium text-[var(--text-primary)]">
className={SETTINGS_LAYOUT.sidebar.container} Settings
style={{ </p>
backgroundColor: "var(--surface-rail)",
borderRight: "1px solid var(--border-subtle)",
}}
>
<div className={SETTINGS_LAYOUT.sidebar.header}>
<div className="flex items-center justify-between">
<h2
className={SETTINGS_LAYOUT.sidebar.headerTitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.settings_nav.user_settings")}
</h2>
</div>
</div> </div>
{NAV_SECTIONS.map((section, si) => ( {NAV_SECTIONS.map((section, si) => (
<div key={si} className={SETTINGS_LAYOUT.sidebar.section}> <div key={si} className="mb-1">
<div <div className="px-4 pt-4 pb-2 text-[11px] font-semibold tracking-[0.22em] text-[var(--text-tertiary)] uppercase">
className={SETTINGS_LAYOUT.sidebar.sectionLabel}
style={{ color: "var(--text-muted)" }}
>
{section.label} {section.label}
</div> </div>
{section.items.map((item) => ( {section.items.map((item) => (
@ -95,19 +124,16 @@ export function SettingsLayout() {
to={item.to} to={item.to}
end={item.end} end={item.end}
className={({ isActive }) => className={({ isActive }) =>
`${SETTINGS_LAYOUT.navItem.default} ${isActive ? SETTINGS_LAYOUT.navItem.active : ""}` [
} "mx-3 flex items-center gap-3 rounded-xl px-3 py-2 text-[14px] transition-all",
style={({ isActive }) => isActive
isActive ? "bg-[var(--hover-bg-strong)] text-[var(--text-primary)] shadow-sm ring-1 ring-[var(--border-default)]"
? { : "text-[var(--text-secondary)] hover:bg-[var(--hover-bg)] hover:text-[var(--text-primary)]",
color: "var(--text-primary)", ].join(" ")
backgroundColor: "var(--hover-bg-strong)",
}
: { color: "var(--text-secondary)" }
} }
viewTransition viewTransition
> >
<item.icon className="w-4 h-4 shrink-0" /> <item.icon className="size-4 shrink-0" />
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
</NavLink> </NavLink>
))} ))}
@ -115,33 +141,34 @@ export function SettingsLayout() {
))} ))}
</nav> </nav>
<div className="flex-1 flex flex-col min-w-0 h-full"> <div className="flex h-full min-w-0 flex-1 flex-col">
<div <div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--border-subtle)] bg-[var(--surface-ground)]/80 px-4 py-3 backdrop-blur-xl">
className={SETTINGS_LAYOUT.topBar.container} <div className="min-w-0">
style={{ <p className="text-[12px] font-semibold tracking-[0.22em] text-[var(--text-tertiary)] uppercase">
borderBottom: "1px solid var(--border-subtle)", {t("settings.settings_nav.user_settings")}
backgroundColor: "var(--surface-ground)", </p>
}} <p className="truncate text-[14px] font-medium text-[var(--text-primary)]">
> {currentSection?.label || "Settings"}
</p>
</div>
<button <button
onClick={handleClose} onClick={handleClose}
className={SETTINGS_LAYOUT.topBar.closeButton} className="inline-flex size-8 items-center justify-center rounded-full border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--hover-bg)] hover:text-[var(--text-primary)]"
style={{ color: "var(--text-secondary)" }} title={t("common.actions.close")}
> >
<XIcon className="w-4 h-4" /> <XIcon className="size-4" />
</button> </button>
</div> </div>
<div <div
ref={contentRef} ref={contentRef}
className={SETTINGS_LAYOUT.content.container} className="flex-1 overflow-y-auto bg-[var(--surface-ground)]"
style={{ backgroundColor: "var(--surface-ground)" }}
> >
<div className={SETTINGS_LAYOUT.content.wrapper}> <div className="mx-auto max-w-[760px] px-6 py-8 lg:px-10">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,325 +1,373 @@
import { useState } from "react"; import { useState } from "react"
import { Button } from "@/components/ui/button"; import {
import { Input } from "@/components/ui/input"; AlertCircle,
import { Label } from "@/components/ui/label"; Check,
import { Plus, Trash2, Key, Check } from "lucide-react"; Edit2,
import { useSshKeysQuery, useAddSshKeyMutation, useDeleteSshKeyMutation, useUpdateSshKeyMutation } from "@/hooks/useSshKeysQuery"; Key,
import { SSH_KEYS_PAGE, SETTINGS_PAGE } from "@/css/app/styles"; Loader2,
import { t } from "@/i18n/T"; Plus,
Save,
Trash2,
X,
} from "lucide-react"
import {
useAddSshKeyMutation,
useDeleteSshKeyMutation,
useSshKeysQuery,
useUpdateSshKeyMutation,
} from "@/hooks/useSshKeysQuery"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Separator } from "@/components/ui/separator"
import { t } from "@/i18n/T"
export function SshKeysPage() { export function SshKeysPage() {
const { data: keys = [], isLoading } = useSshKeysQuery(); const { data: keys = [], isLoading } = useSshKeysQuery()
const addMutation = useAddSshKeyMutation(); const addMutation = useAddSshKeyMutation()
const deleteMutation = useDeleteSshKeyMutation(); const deleteMutation = useDeleteSshKeyMutation()
const updateMutation = useUpdateSshKeyMutation(); const updateMutation = useUpdateSshKeyMutation()
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error"
text: string; text: string
} | null>(null); } | null>(null)
const [showAdd, setShowAdd] = useState(false)
// Add form const [addForm, setAddForm] = useState({ title: "", public_key: "" })
const [showAdd, setShowAdd] = useState(false); const [editingKey, setEditingKey] = useState<number | null>(null)
const [addForm, setAddForm] = useState({ title: "", public_key: "" }); const [editTitle, setEditTitle] = useState("")
// Edit form
const [editingKey, setEditingKey] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState("");
const handleAdd = async () => { const handleAdd = async () => {
if (!addForm.title || !addForm.public_key) { if (!addForm.title || !addForm.public_key) {
setMessage({ type: "error", text: t("settings.ssh_keys.messages.title_key_required") }); setMessage({
return; type: "error",
text: t("settings.ssh_keys.messages.title_key_required"),
})
return
} }
try { try {
await addMutation.mutateAsync({ await addMutation.mutateAsync({
title: addForm.title, title: addForm.title,
public_key: addForm.public_key, public_key: addForm.public_key,
}); })
setMessage({ type: "success", text: t("settings.ssh_keys.messages.add_success") }); setMessage({
setShowAdd(false); type: "success",
setAddForm({ title: "", public_key: "" }); text: t("settings.ssh_keys.messages.add_success"),
})
setShowAdd(false)
setAddForm({ title: "", public_key: "" })
} catch { } catch {
setMessage({ type: "error", text: t("settings.ssh_keys.messages.add_failed") }); setMessage({
type: "error",
text: t("settings.ssh_keys.messages.add_failed"),
})
} }
}; }
const handleDelete = async (keyId: number) => { const handleDelete = async (keyId: number) => {
try { try {
await deleteMutation.mutateAsync(keyId); await deleteMutation.mutateAsync(keyId)
setMessage({ type: "success", text: t("settings.ssh_keys.messages.delete_success") }); setMessage({
type: "success",
text: t("settings.ssh_keys.messages.delete_success"),
})
} catch { } catch {
setMessage({ type: "error", text: t("settings.ssh_keys.messages.delete_failed") }); setMessage({
type: "error",
text: t("settings.ssh_keys.messages.delete_failed"),
})
} }
}; }
const handleSaveTitle = async (keyId: number) => { const handleSaveTitle = async (keyId: number) => {
try { try {
await updateMutation.mutateAsync({ keyId, title: editTitle }); await updateMutation.mutateAsync({ keyId, title: editTitle })
setEditingKey(null); setEditingKey(null)
setMessage({ type: "success", text: t("settings.ssh_keys.messages.title_updated") }); setMessage({
type: "success",
text: t("settings.ssh_keys.messages.title_updated"),
})
} catch { } catch {
setMessage({ type: "error", text: t("settings.ssh_keys.messages.update_failed") }); setMessage({
type: "error",
text: t("settings.ssh_keys.messages.update_failed"),
})
} }
}; }
const formatFingerprint = (fp: string) => { const formatFingerprint = (fp: string) => {
if (fp.length <= 56) return fp; if (fp.length <= 56) return fp
return fp.slice(0, 28) + "..." + fp.slice(-28); return `${fp.slice(0, 28)}...${fp.slice(-28)}`
}; }
if (isLoading) { if (isLoading) {
return ( return (
<div className={SSH_KEYS_PAGE.loadingState}> <div className="flex items-center justify-center py-20">
<div <Loader2
className="w-6 h-6 rounded-full border-2 border-muted border-t-accent animate-spin" className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/> />
</div> </div>
); )
} }
return ( return (
<div> <div className="flex flex-col gap-6">
<div className={SSH_KEYS_PAGE.headerRow}> <div className="flex items-center justify-between gap-4">
<h1 <div className="flex min-w-0 flex-col gap-2">
className={SSH_KEYS_PAGE.pageHeader} <h1
style={{ color: "var(--text-primary)" }} className="text-[20px] font-bold"
> style={{ color: "var(--text-primary)" }}
{t("settings.ssh_keys.title")} >
</h1> {t("settings.ssh_keys.title")}
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
{t("settings.ssh_keys.description")}
</p>
</div>
<Button <Button
onClick={() => { onClick={() => {
setShowAdd(true); setShowAdd(true)
setMessage(null); setMessage(null)
}} }}
size="sm" size="sm"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
> >
<Plus className="w-3.5 h-3.5 mr-1.5" /> <Plus className="mr-2 size-4" />
{t("settings.ssh_keys.add_button")} {t("settings.ssh_keys.add_button")}
</Button> </Button>
</div> </div>
<p
className={SSH_KEYS_PAGE.pageSubtitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.ssh_keys.description")}
</p>
{/* Add Form */} {message && (
{showAdd && ( <Alert
<div variant={message.type === "error" ? "destructive" : undefined}
className={SSH_KEYS_PAGE.addForm} className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
style={{
border: "1px solid var(--accent)",
backgroundColor: "var(--surface-elevated)",
}}
> >
<h3 <AlertCircle className="size-4" />
className={SSH_KEYS_PAGE.addFormTitle} <AlertDescription>{message.text}</AlertDescription>
style={{ color: "var(--text-primary)" }} </Alert>
>
{t("settings.ssh_keys.add_title")}
</h3>
<div className={SSH_KEYS_PAGE.addFormFields}>
<div>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.ssh_keys.title_label")}
</Label>
<Input
value={addForm.title}
onChange={(e) =>
setAddForm((f) => ({ ...f, title: e.target.value }))
}
placeholder={t("settings.ssh_keys.name_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-ground)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
<div>
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.ssh_keys.public_key_label")}
</Label>
<textarea
value={addForm.public_key}
onChange={(e) =>
setAddForm((f) => ({ ...f, public_key: e.target.value }))
}
placeholder={t("settings.ssh_keys.key_placeholder")}
rows={4}
className="w-full text-[13px] px-3 py-2 rounded-md font-mono resize-none"
style={{
backgroundColor: "var(--surface-ground)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
</div>
<div className={SSH_KEYS_PAGE.addFormButtons}>
<Button
onClick={handleAdd}
disabled={addMutation.isPending}
size="sm"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
>
{addMutation.isPending && (
<div className="w-3.5 h-3.5 mr-1.5 rounded-full border-2 border-accent-fg/30 border-t-accent-fg animate-spin" />
)}
{t("common.actions.add")}
</Button>
<Button
onClick={() => {
setShowAdd(false);
setAddForm({ title: "", public_key: "" });
}}
variant="ghost"
size="sm"
>
{t("common.actions.cancel")}
</Button>
</div>
</div>
)} )}
{/* Keys List */} {showAdd && (
{keys.length === 0 ? ( <Card>
<div <CardHeader className="gap-2">
className={SSH_KEYS_PAGE.emptyState} <CardTitle className="text-[15px]">
style={{ {t("settings.ssh_keys.add_title")}
backgroundColor: "var(--surface-elevated)", </CardTitle>
color: "var(--text-muted)", <CardDescription>
}} Register a new SSH public key for this account.
> </CardDescription>
<Key className="w-10 h-10 mx-auto mb-3 opacity-40" /> </CardHeader>
<p className="text-[14px]">{t("settings.ssh_keys.empty_title")}</p> <CardContent className="flex flex-col gap-5">
<p className="text-[12px] mt-1"> <FieldGroup>
{t("settings.ssh_keys.empty_desc")} <Field>
</p> <FieldLabel htmlFor="ssh-key-title">
</div> <FieldTitle>{t("settings.ssh_keys.title_label")}</FieldTitle>
) : ( <FieldDescription>
<div className={SSH_KEYS_PAGE.keysList}> {t("settings.ssh_keys.name_placeholder")}
{keys.map((key) => ( </FieldDescription>
<div </FieldLabel>
key={key.id} <FieldContent>
className={SSH_KEYS_PAGE.keyItem} <Input
style={{ id="ssh-key-title"
backgroundColor: "var(--surface-elevated)", value={addForm.title}
border: "1px solid var(--border-subtle)", onChange={(e) =>
}} setAddForm((f) => ({ ...f, title: e.target.value }))
> }
<div className={SSH_KEYS_PAGE.keyItemHeader}> placeholder={t("settings.ssh_keys.name_placeholder")}
<div className="flex-1 min-w-0"> />
{editingKey === key.id ? ( </FieldContent>
<div className={SSH_KEYS_PAGE.keyTitleEdit}> </Field>
<Input
value={editTitle} <Field>
onChange={(e) => setEditTitle(e.target.value)} <FieldLabel htmlFor="ssh-key-public">
className={`${SETTINGS_PAGE.formInput} h-7`} <FieldTitle>
style={{ {t("settings.ssh_keys.public_key_label")}
backgroundColor: "var(--surface-ground)", </FieldTitle>
borderColor: "var(--border-default)", <FieldDescription>
color: "var(--text-primary)", Paste the full public key in one line or multiple lines.
}} </FieldDescription>
/> </FieldLabel>
<button <FieldContent>
onClick={() => handleSaveTitle(key.id)} <Textarea
className="flex items-center justify-center w-7 h-7 rounded-[4px]" id="ssh-key-public"
style={{ color: "var(--success)" }} value={addForm.public_key}
> onChange={(e) =>
<Check className="w-4 h-4" /> setAddForm((f) => ({ ...f, public_key: e.target.value }))
</button> }
</div> placeholder={t("settings.ssh_keys.key_placeholder")}
) : ( rows={4}
<p className="min-h-28 font-mono text-[13px]"
className={SSH_KEYS_PAGE.keyTitle} />
style={{ color: "var(--text-primary)" }} </FieldContent>
onClick={() => { </Field>
setEditingKey(key.id); </FieldGroup>
setEditTitle(key.title);
}} <div className="flex items-center justify-end gap-2">
> <Button
{key.title} variant="ghost"
</p> size="sm"
)} onClick={() => {
<p setShowAdd(false)
className={SSH_KEYS_PAGE.keyFingerprint} setAddForm({ title: "", public_key: "" })
style={{ color: "var(--text-muted)" }} }}
> >
{formatFingerprint(key.fingerprint)} <X className="mr-2 size-4" />
</p> {t("common.actions.cancel")}
<div className={SSH_KEYS_PAGE.keyMeta}> </Button>
<span <Button
className={SSH_KEYS_PAGE.keyBadge} onClick={handleAdd}
style={{ disabled={addMutation.isPending}
backgroundColor: "var(--hover-bg-strong)", size="sm"
color: "var(--text-muted)", >
}} {addMutation.isPending && (
> <Loader2 className="mr-2 size-4 animate-spin" />
{key.key_type} )}
</span> {t("common.actions.add")}
{key.key_bits && ( </Button>
<span
className="text-[11px]"
style={{ color: "var(--text-muted)" }}
>
{key.key_bits} bits
</span>
)}
{key.is_verified && (
<span
className="text-[11px] flex items-center gap-0.5"
style={{ color: "var(--success)" }}
>
<Check className="w-3 h-3" />
{t("settings.ssh_keys.verified")}
</span>
)}
</div>
</div>
<button
onClick={() => handleDelete(key.id)}
className={SSH_KEYS_PAGE.deleteButton}
style={{ color: "var(--text-muted)" }}
title={t("common.actions.delete")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div> </div>
</CardContent>
</Card>
)}
{keys.length === 0 ? (
<Empty className="border border-dashed border-[var(--border-subtle)] bg-[var(--surface-secondary)]/70 py-12">
<EmptyHeader>
<EmptyMedia variant="icon">
<Key />
</EmptyMedia>
<EmptyTitle>{t("settings.ssh_keys.empty_title")}</EmptyTitle>
<EmptyDescription>
{t("settings.ssh_keys.empty_desc")}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-3">
{keys.map((key) => (
<Card key={key.id} size="sm">
<CardContent className="flex flex-col gap-4 pt-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{editingKey === key.id ? (
<div className="flex items-center gap-2">
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="h-8"
placeholder={t("settings.ssh_keys.name_placeholder")}
/>
<Button
size="sm"
variant="ghost"
onClick={() => handleSaveTitle(key.id)}
>
<Save className="size-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingKey(null)
setEditTitle("")
}}
>
<X className="size-4" />
</Button>
</div>
) : (
<button
className="flex w-full items-center justify-start gap-2 text-left"
onClick={() => {
setEditingKey(key.id)
setEditTitle(key.title)
}}
>
<p
className="truncate text-[14px] font-semibold"
style={{ color: "var(--text-primary)" }}
>
{key.title}
</p>
<Edit2
className="size-3.5"
style={{ color: "var(--text-muted)" }}
/>
</button>
)}
<p
className="mt-1 font-mono text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{formatFingerprint(key.fingerprint)}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDelete(key.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
<Separator />
<div className="flex flex-wrap items-center gap-2 text-[12px]">
<Badge
variant="secondary"
className="rounded-full px-2.5 py-1"
>
{key.key_type}
</Badge>
{key.key_bits && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-1"
>
{key.key_bits} bits
</Badge>
)}
{key.is_verified && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-1 text-[var(--success)]"
>
<Check className="size-3" />
{t("settings.ssh_keys.verified")}
</Badge>
)}
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}
{message && (
<div
className={SSH_KEYS_PAGE.message}
style={{
color:
message.type === "success"
? "var(--success)"
: "var(--destructive)",
}}
>
{message.text}
</div>
)}
</div> </div>
); )
} }