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 type { AccessKeyResponse } from "@/client/model";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, Trash2, Key, Copy, Check } from "lucide-react";
import { format } from "date-fns";
import { useAccessKeysQuery, useCreateAccessKeyMutation, useDeleteAccessKeyMutation } from "@/hooks/useAccessKeysQuery";
import { t } from "@/i18n/T";
import { useState } from "react"
import { format } from "date-fns"
import {
AlertCircle,
Check,
Copy,
Key,
Loader2,
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() {
const { data: keys = [], isLoading } = useAccessKeysQuery();
const createMutation = useCreateAccessKeyMutation();
const deleteMutation = useDeleteAccessKeyMutation();
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ name: "" });
const [newKey, setNewKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { data: keys = [], isLoading } = useAccessKeysQuery()
const createMutation = useCreateAccessKeyMutation()
const deleteMutation = useDeleteAccessKeyMutation()
const [showAdd, setShowAdd] = useState(false)
const [addForm, setAddForm] = useState({ name: "" })
const [newKey, setNewKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
type: "success" | "error"
text: string
} | null>(null)
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleAdd = async () => {
if (!addForm.name.trim()) {
setMessage({ type: "error", text: t("settings.access_keys.messages.name_required") });
return;
setMessage({
type: "error",
text: t("settings.access_keys.messages.name_required"),
})
return
}
try {
const res = await createMutation.mutateAsync({ name: addForm.name.trim(), scopes: [] });
// The API returns the new key only once in the response
const res = await createMutation.mutateAsync({
name: addForm.name.trim(),
scopes: [],
})
if (res?.access_key) {
setNewKey(res.access_key);
setNewKey(res.access_key)
}
setMessage({ type: "success", text: t("settings.access_keys.messages.created_success") });
setShowAdd(false);
setAddForm({ name: "" });
setMessage({
type: "success",
text: t("settings.access_keys.messages.created_success"),
})
setShowAdd(false)
setAddForm({ name: "" })
} 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) => {
try {
await deleteMutation.mutateAsync(keyId);
setMessage({ type: "success", text: t("settings.access_keys.messages.delete_success") });
await deleteMutation.mutateAsync(keyId)
setMessage({
type: "success",
text: t("settings.access_keys.messages.delete_success"),
})
} 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) {
return (
<div className="flex items-center justify-center py-20">
<div
className="w-6 h-6 rounded-full border-2 border-muted border-t-accent animate-spin"
style={{ borderColor: "var(--border-default)", borderTopColor: "var(--accent)" }}
<Loader2
className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div>
);
)
}
return (
<div>
<div className="flex items-center justify-between mb-1">
<h1 className="text-[20px] font-bold" style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.title")}</h1>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<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
onClick={() => {
setShowAdd(true);
setNewKey(null);
setMessage(null);
setShowAdd(true)
setNewKey(null)
setMessage(null)
}}
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")}
</Button>
</div>
<p className="text-[13px] mb-6" style={{ color: "var(--text-muted)" }}>
{t("settings.access_keys.description")}
</p>
{/* New Token Display */}
{newKey && (
<div className="mb-6 p-4 rounded-lg" style={{ backgroundColor: "var(--success-alpha10)", border: "1px solid var(--success)" }}>
<h3 className="text-[14px] font-semibold mb-2" style={{ color: "var(--success)" }}>
{t("settings.access_keys.copy_warning")}
</h3>
<div className="flex items-center gap-2">
<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)" }}>
{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>
{message && !newKey && (
<Alert
variant={message.type === "error" ? "destructive" : undefined}
className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
>
<AlertCircle className="size-4" />
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
{/* Add Form */}
{showAdd && !newKey && (
<div className="mb-6 p-4 rounded-lg border" style={{ borderColor: "var(--accent)", backgroundColor: "var(--surface-elevated)" }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.generate_button")}</h3>
<div className="space-y-4">
<div>
<Label className="text-[12px] font-semibold uppercase mb-1 block" style={{ color: "var(--text-muted)" }}>{t("settings.access_keys.token_name")}</Label>
<Input
value={addForm.name}
onChange={(e) => setAddForm({ name: e.target.value })}
placeholder="e.g. CLI Token"
style={{ backgroundColor: "var(--input-bg)", border: "none" }}
/>
{newKey && (
<Card
className="bg-[var(--success-alpha10)]"
style={{
borderColor: "color-mix(in srgb, var(--success) 30%, transparent)",
}}
>
<CardHeader className="gap-2">
<CardTitle className="text-[15px] text-[var(--success)]">
{t("settings.access_keys.copy_warning")}
</CardTitle>
<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 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
onClick={handleAdd}
disabled={createMutation.isPending}
size="sm"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
>
{createMutation.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"
style={{ borderColor: "color-mix(in srgb, var(--accent-fg) 30%, transparent)", borderTopColor: "var(--accent-fg)" }}
/>
<Loader2 className="mr-2 size-4 animate-spin" />
)}
{t("settings.access_keys.generate_token")}
</Button>
<Button onClick={() => setShowAdd(false)} variant="ghost" size="sm" style={{ color: "var(--text-secondary)" }}>{t("common.actions.cancel")}</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Keys List */}
{keys.length === 0 ? (
<div className="text-center py-12 rounded-lg border" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
<Key className="w-10 h-10 mx-auto mb-3 opacity-20" style={{ color: "var(--text-muted)" }} />
<p style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.empty")}</p>
</div>
<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.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) => (
<div
key={key.id}
className="p-4 rounded-lg border flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
>
<div className="min-w-0">
<p className="text-[14px] font-semibold truncate" style={{ color: "var(--text-primary)" }}>{key.name}</p>
<div className="flex items-center gap-3 mt-1 text-[12px]" style={{ color: "var(--text-muted)" }}>
<span>{t("settings.access_keys.created_on")} {format(new Date(key.created_at), "MMM d, yyyy")}</span>
{key.expires_at && (
<>
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: "var(--text-muted)" }} />
<span>{t("settings.access_keys.expires")} {format(new Date(key.expires_at), "MMM d, yyyy")}</span>
</>
)}
<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">
<p
className="truncate text-[14px] font-semibold"
style={{ color: "var(--text-primary)" }}
>
{key.name}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[12px]">
<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>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(key.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<Separator />
<div className="text-[12px] text-muted-foreground">
Key #{key.id} will be removed immediately after deletion.
</div>
</CardContent>
</Card>
))}
</div>
)}
{message && !newKey && (
<div className={`mt-4 text-[13px]`} style={{ color: message.type === "success" ? "var(--success)" : "var(--destructive)" }}>
{message.text}
</div>
)}
</div>
);
)
}

View File

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

View File

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

View File

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

View File

@ -1,336 +1,353 @@
import { useEffect, useState, useRef } from "react";
import { getMyProfile, updateMyProfile, uploadAvatar } from "@/client/api";
import type { ProfileResponse, UpdateProfileParams } from "@/client/model";
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";
import { useEffect, useState, useRef } from "react"
import { getMyProfile, updateMyProfile, uploadAvatar } from "@/client/api"
import type { ProfileResponse, UpdateProfileParams } from "@/client/model"
import { Alert, AlertDescription } from "@/components/ui/alert"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} 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 {
display_name?: string | null;
display_name?: string | null
}
export function MyAccountPage() {
const { profile: cachedProfile, setProfile: setCachedProfile } = useSettingsDataCache();
const [profile, setProfile] = useState<ProfileResponse | null>(cachedProfile);
const [loading, setLoading] = useState(!cachedProfile);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { profile: cachedProfile, setProfile: setCachedProfile } =
useSettingsDataCache()
const [profile, setProfile] = useState<ProfileResponse | null>(cachedProfile)
const [loading, setLoading] = useState(!cachedProfile)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
display_name: cachedProfile?.display_name ?? "",
avatar_url: cachedProfile?.avatar_url ?? "",
website_url: cachedProfile?.website_url ?? "",
organization: cachedProfile?.organization ?? "",
});
})
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
type: "success" | "error"
text: string
} | null>(null)
useEffect(() => {
if (cachedProfile) return;
(async () => {
if (cachedProfile) return
;(async () => {
try {
const res = await getMyProfile();
const d = res.data.data!;
setProfile(d);
setCachedProfile(d);
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") });
setMessage({
type: "error",
text: t("settings.my_account.load_failed"),
})
} finally {
setLoading(false);
setLoading(false)
}
})();
}, [cachedProfile, setCachedProfile]);
})()
}, [cachedProfile, setCachedProfile])
const handleSave = async () => {
try {
setSaving(true);
setMessage(null);
setSaving(true)
setMessage(null)
await updateMyProfile({
display_name: form.display_name || null,
avatar_url: form.avatar_url || null,
website_url: form.website_url || null,
organization: form.organization || null,
} as UpdateProfileRequest);
setMessage({ type: "success", text: t("settings.my_account.save_success") });
await loadProfile();
} as UpdateProfileRequest)
const nextProfile = {
...(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 {
setMessage({ type: "error", text: t("settings.my_account.save_failed") });
setMessage({ type: "error", text: t("settings.my_account.save_failed") })
} 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 file = e.target.files?.[0];
if (!file) return;
const file = e.target.files?.[0]
if (!file) return
if (file.size > 2 * 1024 * 1024) {
setMessage({ type: "error", text: t("settings.my_account.avatar_size_error") });
return;
setMessage({
type: "error",
text: t("settings.my_account.avatar_size_error"),
})
return
}
try {
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append("file", file);
setUploading(true)
setMessage(null)
const formData = new FormData()
formData.append("file", file)
const res = await uploadAvatar(formData);
const newAvatarUrl = res.data.data?.avatar_url;
const res = await uploadAvatar(formData)
const newAvatarUrl = res.data.data?.avatar_url
if (newAvatarUrl) {
setForm(f => ({ ...f, avatar_url: newAvatarUrl }));
setMessage({ type: "success", text: t("settings.my_account.avatar_upload_success") });
setForm((f) => ({ ...f, avatar_url: newAvatarUrl }))
setMessage({
type: "success",
text: t("settings.my_account.avatar_upload_success"),
})
}
} catch {
setMessage({ type: "error", text: t("settings.my_account.avatar_upload_failed") });
setMessage({
type: "error",
text: t("settings.my_account.avatar_upload_failed"),
})
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
};
}
const removeAvatar = () => {
setForm(f => ({ ...f, avatar_url: "" }));
};
setForm((f) => ({ ...f, avatar_url: "" }))
}
if (loading) {
return (
<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>
);
)
}
return (
<div>
<h1
className={SETTINGS_PAGE.pageHeader}
style={{ color: "var(--text-primary)" }}
>
{t("settings.my_account.title")}
</h1>
<p
className={SETTINGS_PAGE.pageSubtitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.my_account.subtitle")}
</p>
{/* Avatar Section */}
<div className={SETTINGS_PAGE.avatarSection}>
<Label
className={SETTINGS_PAGE.formLabel}
<div className="flex flex-col gap-6">
<div className="space-y-2">
<h1
className={SETTINGS_PAGE.pageHeader}
style={{ color: "var(--text-primary)" }}
>
{t("settings.my_account.title")}
</h1>
<p
className={SETTINGS_PAGE.pageSubtitle}
style={{ color: "var(--text-muted)" }}
>
{t("settings.my_account.avatar")}
</Label>
<div className={SETTINGS_PAGE.avatarRow}>
<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>
{t("settings.my_account.subtitle")}
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Card>
<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
size="sm"
variant="outline"
className="h-8"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
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" />}
{t("settings.my_account.upload_avatar")}
{uploading ? (
<Loader2
data-icon="inline-start"
className="animate-spin"
/>
) : (
<Upload data-icon="inline-start" />
)}
{t("settings.my_account.upload_avatar")}
</Button>
{form.avatar_url && (
<Button
size="sm"
variant="ghost"
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={removeAvatar}
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
{t("settings.my_account.remove")}
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={removeAvatar}
>
<Trash2 data-icon="inline-start" />
{t("settings.my_account.remove")}
</Button>
)}
</div>
<p className={SETTINGS_PAGE.avatarHint}>
{t("settings.my_account.avatar_hint")}
</p>
<input
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
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>
{/* Form Fields */}
<div className={SETTINGS_PAGE.formSection}>
<div className={SETTINGS_PAGE.formGroup}>
<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 className="grid gap-2">
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.my_account.display_name")}
</Label>
<Input
value={form.display_name}
onChange={(e) =>
setForm((f) => ({ ...f, display_name: e.target.value }))
}
placeholder={t("settings.my_account.display_name_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.my_account.display_name")}
</Label>
<Input
value={form.display_name}
onChange={(e) =>
setForm((f) => ({ ...f, display_name: e.target.value }))
}
placeholder={t("settings.my_account.display_name_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
<div className="grid gap-2">
<Label
className={SETTINGS_PAGE.formLabel}
style={{ color: "var(--text-muted)" }}
>
{t("settings.my_account.website")}
</Label>
<Input
value={form.website_url}
onChange={(e) =>
setForm((f) => ({ ...f, website_url: e.target.value }))
}
placeholder={t("settings.my_account.website_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.my_account.website")}
</Label>
<Input
value={form.website_url}
onChange={(e) =>
setForm((f) => ({ ...f, website_url: e.target.value }))
}
placeholder={t("settings.my_account.website_placeholder")}
className={SETTINGS_PAGE.formInput}
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
color: "var(--text-primary)",
}}
/>
</div>
<div className="grid gap-2">
<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>
</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 && (
<div
className={SETTINGS_PAGE.message}
style={{
color:
message.type === "success"
? "var(--success)"
: "var(--destructive)",
}}
<Alert
variant={message.type === "error" ? "destructive" : "default"}
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
>
{message.text}
</div>
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
{/* Save Button */}
<div className={SETTINGS_PAGE.buttonRow}>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
size="sm"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
className="min-w-28"
>
{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")}
</Button>
</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 {
getNotificationPreferences,
updateNotificationPreferences,
} from "@/client/api";
import type { NotificationPreferencesResponse } from "@/client/model";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
} from "@/client/api"
import type { NotificationPreferencesResponse } from "@/client/model"
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache";
import { NOTIFICATIONS_PAGE } from "@/css/app/styles";
import { t } from "@/i18n/T";
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { t } from "@/i18n/T"
const DIGEST_MODES = [
{ value: "instant", label: t("settings.notifications_page.instant") },
{ value: "daily", label: t("settings.notifications_page.daily_digest") },
{ value: "weekly", label: t("settings.notifications_page.weekly_digest") },
{ value: "off", label: t("settings.notifications_page.off") },
];
]
const ToggleRow = ({
function ToggleRow({
label,
desc,
checked,
onChange,
}: {
label: string;
desc: string;
checked: boolean;
onChange: (v: boolean) => void;
}) => (
<div className={NOTIFICATIONS_PAGE.toggleRow}>
<div className="flex-1 pr-4">
<p className={NOTIFICATIONS_PAGE.toggleLabel} style={{ color: "var(--text-primary)" }}>
{label}
</p>
<p className={NOTIFICATIONS_PAGE.toggleLabelDesc} style={{ color: "var(--text-muted)" }}>
{desc}
</p>
label: string
desc: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-start justify-between gap-4 py-4">
<div className="min-w-0">
<p
className="text-[14px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{label}
</p>
<p
className="mt-0.5 text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{desc}
</p>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
)
}
export function NotificationsPage() {
const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache();
const [, setPrefs] =
useState<NotificationPreferencesResponse | null>(cachedPrefs);
const [loading, setLoading] = useState(!cachedPrefs);
const [saving, setSaving] = useState(false);
const {
notificationPrefs: cachedPrefs,
setNotificationPrefs: setCachedPrefs,
} = useSettingsDataCache()
const [, setPrefs] = useState<NotificationPreferencesResponse | null>(
cachedPrefs
)
const [loading, setLoading] = useState(!cachedPrefs)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
email_enabled: cachedPrefs?.email_enabled ?? true,
in_app_enabled: cachedPrefs?.in_app_enabled ?? true,
@ -65,42 +93,45 @@ export function NotificationsPage() {
marketing_enabled: cachedPrefs?.marketing_enabled ?? false,
security_enabled: cachedPrefs?.security_enabled ?? true,
product_enabled: cachedPrefs?.product_enabled ?? true,
});
})
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
type: "success" | "error"
text: string
} | null>(null)
useEffect(() => {
if (cachedPrefs) return;
(async () => {
if (cachedPrefs) return
;(async () => {
try {
const res = await getNotificationPreferences();
const d = res.data.data!;
setPrefs(d);
setCachedPrefs(d);
const res = await getNotificationPreferences()
const data = res.data.data!
setPrefs(data)
setCachedPrefs(data)
setForm({
email_enabled: d.email_enabled,
in_app_enabled: d.in_app_enabled,
push_enabled: d.push_enabled,
digest_mode: d.digest_mode,
dnd_enabled: d.dnd_enabled,
marketing_enabled: d.marketing_enabled,
security_enabled: d.security_enabled,
product_enabled: d.product_enabled,
});
email_enabled: data.email_enabled,
in_app_enabled: data.in_app_enabled,
push_enabled: data.push_enabled,
digest_mode: data.digest_mode,
dnd_enabled: data.dnd_enabled,
marketing_enabled: data.marketing_enabled,
security_enabled: data.security_enabled,
product_enabled: data.product_enabled,
})
} catch {
setMessage({ type: "error", text: t("settings.notifications_page.load_failed") });
setMessage({
type: "error",
text: t("settings.notifications_page.load_failed"),
})
} finally {
setLoading(false);
setLoading(false)
}
})();
}, [cachedPrefs, setCachedPrefs]);
})()
}, [cachedPrefs, setCachedPrefs])
const handleSave = async () => {
try {
setSaving(true);
setMessage(null);
setSaving(true)
setMessage(null)
await updateNotificationPreferences({
email_enabled: form.email_enabled,
in_app_enabled: form.in_app_enabled,
@ -110,228 +141,202 @@ export function NotificationsPage() {
marketing_enabled: form.marketing_enabled,
security_enabled: form.security_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 {
setMessage({ type: "error", text: t("settings.notifications_page.save_failed") });
setMessage({
type: "error",
text: t("settings.notifications_page.save_failed"),
})
} finally {
setSaving(false);
setSaving(false)
}
};
}
if (loading) {
return (
<div className={NOTIFICATIONS_PAGE.loadingState}>
<div className="flex items-center justify-center py-20">
<Loader2
className="w-6 h-6 animate-spin"
className="size-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div>
);
)
}
return (
<div>
<h1
className={NOTIFICATIONS_PAGE.pageHeader}
style={{ color: "var(--text-primary)" }}
>
{t("settings.notifications_page.title")}
</h1>
<p
className={NOTIFICATIONS_PAGE.pageSubtitle}
style={{ color: "var(--text-muted)" }}
>
{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 className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1
className="text-[20px] font-bold"
style={{ color: "var(--text-primary)" }}
>
{t("settings.notifications_page.title")}
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
{t("settings.notifications_page.subtitle")}
</p>
</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 && (
<div
className={NOTIFICATIONS_PAGE.message}
style={{
color:
message.type === "success"
? "var(--success)"
: "var(--destructive)",
}}
<Alert
variant={message.type === "error" ? "destructive" : undefined}
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
>
{message.text}
</div>
<AlertDescription>{message.text}</AlertDescription>
</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
onClick={handleSave}
disabled={saving}
size="sm"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
className="min-w-28"
>
{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")}
</Button>
</div>
</div>
);
}
)
}

View File

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

View File

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

View File

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

View File

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