diff --git a/src/app/settings/AccessKeysPage.tsx b/src/app/settings/AccessKeysPage.tsx index 3602dbe..f941fbb 100644 --- a/src/app/settings/AccessKeysPage.tsx +++ b/src/app/settings/AccessKeysPage.tsx @@ -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(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(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 (
-
- ); + ) } return ( -
-
-

{t("settings.access_keys.title")}

+
+
+
+

+ {t("settings.access_keys.title")} +

+

+ {t("settings.access_keys.description")} +

+
-

- {t("settings.access_keys.description")} -

- {/* New Token Display */} - {newKey && ( -
-

- {t("settings.access_keys.copy_warning")} -

-
-
- {newKey} -
- -
-
+ {message && !newKey && ( + + + {message.text} + )} - {/* Add Form */} - {showAdd && !newKey && ( -
-

{t("settings.access_keys.generate_button")}

-
-
- - setAddForm({ name: e.target.value })} - placeholder="e.g. CLI Token" - style={{ backgroundColor: "var(--input-bg)", border: "none" }} - /> + {newKey && ( + + + + {t("settings.access_keys.copy_warning")} + + + This token is shown once. Copy it now and store it securely. + + + +
+ {newKey}
-
+
+ +
+ + + )} + + {showAdd && !newKey && ( + + + + {t("settings.access_keys.generate_button")} + + + Create a token for scripts, CLI tools, or local integrations. + + + + + + + + {t("settings.access_keys.token_name")} + + + Give the token a recognizable name. + + + + setAddForm({ name: e.target.value })} + placeholder="e.g. CLI Token" + /> + + + + +
+ -
-
-
+ + )} - {/* Keys List */} {keys.length === 0 ? ( -
- -

{t("settings.access_keys.empty")}

-
+ + + + + + {t("settings.access_keys.empty")} + + Access tokens let you automate against the API without using your + password. + + + ) : ( -
+
{keys.map((key: AccessKeyResponse) => ( -
-
-

{key.name}

-
- {t("settings.access_keys.created_on")} {format(new Date(key.created_at), "MMM d, yyyy")} - {key.expires_at && ( - <> - - {t("settings.access_keys.expires")} {format(new Date(key.expires_at), "MMM d, yyyy")} - - )} + + +
+
+

+ {key.name} +

+
+ + {t("settings.access_keys.created_on")}{" "} + {format(new Date(key.created_at), "MMM d, yyyy")} + + {key.expires_at && ( + + {t("settings.access_keys.expires")}{" "} + {format(new Date(key.expires_at), "MMM d, yyyy")} + + )} +
+
+ +
-
- -
+ + + +
+ Key #{key.id} will be removed immediately after deletion. +
+ + ))}
)} - - {message && !newKey && ( -
- {message.text} -
- )}
- ); + ) } diff --git a/src/app/settings/AppearancePage.tsx b/src/app/settings/AppearancePage.tsx index e31c281..3511ddb 100644 --- a/src/app/settings/AppearancePage.tsx +++ b/src/app/settings/AppearancePage.tsx @@ -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 }[]; -}) => ( -
- - -
-); + label: string + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] + description: string +}) { + return ( + + + {label} + {description} + + + + + + ) +} export function AppearancePage() { - const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache(); - const [, setPrefs] = useState(cachedPrefs); - const [loading, setLoading] = useState(!cachedPrefs); - const [saving, setSaving] = useState(false); + const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = + useSettingsDataCache() + const [, setPrefs] = useState(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 ( -
+
- ); + ) } - 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 ( -
-

- {t("settings.appearance_page.title")} -

-

- {t("settings.appearance_page.subtitle")} -

- - - +
+

- + {t("settings.appearance_page.title")} +

+

+ {t("settings.appearance_page.subtitle")} +

+
+ + + + {t("settings.appearance_page.theme_scheme")} - + {t("settings.appearance_page.basic_settings") || "Basic Settings"} - - + + {t("settings.appearance_page.custom") || "Custom"} - -

- {t("settings.appearance_page.select_theme_scheme")} -

- + + + + + + {t("settings.appearance_page.select_theme_scheme")} + + + Choose a preset that matches your preferred visual tone. + + + + + + - -
-
-

- {t("settings.appearance_page.theme")} -

-
- {themes.map((themeOption) => ( - - ))} -
-
+ + + + + {t("settings.appearance_page.basic_settings") || + "Basic Settings"} + + + Tune the general locale and theme behavior for the app. + + + + + + + + {t("settings.appearance_page.theme")} + + + Select the default color mode for the interface. + + + + { + if (!v) return + setForm((f) => ({ ...f, theme: v })) + }} + className="grid w-full grid-cols-3" + > + {THEMES.map((themeOption) => ( + +
+ {themeOption.value === "dark" + ? "D" + : themeOption.value === "light" + ? "L" + : "S"} +
+ + {themeOption.label} + +
+ ))} +
+
+
- setForm((f) => ({ ...f, language: v }))} - options={LANGUAGES} - /> + setForm((f) => ({ ...f, language: v }))} + options={LANGUAGES} + /> - setForm((f) => ({ ...f, timezone: v }))} - options={TIMEZONES} - /> -
+ setForm((f) => ({ ...f, timezone: v }))} + options={TIMEZONES} + /> + + + +
+ + + + + + {t("settings.appearance_page.custom") || "Custom"} + + + Adjust token-level theme variables and fine-grained styling. + + + + + + + +
{message && ( -
- {message.text} -
+ {message.text} + )} -
+
+ + {form.theme} +
- - - - - -
- ); -} \ No newline at end of file + ) +} diff --git a/src/app/settings/BillingPage.tsx b/src/app/settings/BillingPage.tsx index 35adfd0..db8ec13 100644 --- a/src/app/settings/BillingPage.tsx +++ b/src/app/settings/BillingPage.tsx @@ -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
; - if (!billing) return
{t("settings.billing.load_failed")}
; + if (bLoading) { + return ( +
+ +
+ ) + } - const hasErrors = !!errors?.list && errors.list.length > 0; + if (!billing) { + return ( + + + {t("settings.billing.load_failed")} + + ) + } + + const hasErrors = !!errors?.list && errors.list.length > 0 return ( -
- {/* Billing Errors Banner */} +
{hasErrors && errors?.list && ( -
-
- -

{t("settings.billing.billing_errors")}

-
-
- {errors!.list.map(err => ( -
-
- - {err.error_type === "insufficient_balance" ? t("settings.billing.insufficient_balance") : err.error_type} + + + {t("settings.billing.billing_errors")} + + {errors.list.map((err) => ( +
+
+ + {err.error_type === "insufficient_balance" + ? t("settings.billing.insufficient_balance") + : err.error_type} - + {new Date(err.created_at).toLocaleString()}
-

{err.message}

+

+ {err.message} +

))} -
-
+ + )} - {/* Current Balance */} -
-

{t("settings.billing.personal_billing")}

- -
-
-
- - {t("settings.billing.balance")} -
-

- {billing.currency} {billing.balance.toFixed(2)} -

-
- {billing.is_pro && ( -
-
- - {t("settings.billing.monthly_quota")} + + + + {t("settings.billing.personal_billing")} + + + {billing.is_pro ? "Pro plan billing" : "Free plan billing"} + + + +
+
+
+ + + {t("settings.billing.balance")} +
-

+

+ {billing.currency} {billing.balance.toFixed(2)} +

+
+
+
+ + + {t("settings.billing.monthly_quota")} + +
+

{billing.currency} {billing.monthly_quota.toFixed(2)}

+
+ + {billing.is_pro && billing.monthly_quota > 0 && ( +
+
+ + {t("settings.billing.monthly_usage")} + + + {billing.currency} {billing.month_used.toFixed(2)} /{" "} + {billing.currency} {billing.monthly_quota.toFixed(2)} + +
+
+
0.9 + ? "var(--destructive)" + : "var(--success)", + }} + /> +
+
)} -
- {billing.is_pro && billing.monthly_quota > 0 && ( -
-
- {t("settings.billing.monthly_usage")} - {billing.currency} {billing.month_used.toFixed(2)} / {billing.currency} {billing.monthly_quota.toFixed(2)} -
-
- {billing.monthly_quota > 0 && ( -
0.9 ? "var(--destructive)" : "var(--success)" }} /> - )} -
+
+ + + {billing.currency} {billing.is_pro ? "Pro" : "Free"} +
- )} + + -
-
- - {billing.currency} {billing.is_pro ? "Pro" : "Free"} -
-
-
- - {/* Billing History */} -
-

- {t("settings.billing.history")} {history ? `(${history.total})` : ""} -

- {hLoading ? : - !history?.list?.length ?

{t("settings.billing.no_history")}

: -
+ + + + {t("settings.billing.history")}{" "} + {history ? `(${history.total})` : ""} + + Recent billing activity + + + {hLoading ? ( +
+ +
+ ) : !history?.list?.length ? ( + + + + + + {t("settings.billing.no_history")} + + No billing activity has been recorded yet. + + + + ) : ( +
- - - - + + + + - {history.list.map(item => ( - - - - + + + @@ -142,8 +310,9 @@ export function BillingPage() {
{t("settings.billing.date")}{t("settings.billing.reason")}{t("settings.billing.amount")}
+ {t("settings.billing.date")} + + {t("settings.billing.reason")} + + {t("settings.billing.amount")} +
{new Date(item.created_at).toLocaleDateString()}{item.reason} + {history.list.map((item, index) => ( +
+ {new Date(item.created_at).toLocaleDateString()} + + {item.reason} + {billing.currency} {Number(item.amount).toFixed(4)}
- } -
+ )} + +
- ); -} \ No newline at end of file + ) +} diff --git a/src/app/settings/EmailPage.tsx b/src/app/settings/EmailPage.tsx index 9ee96d1..ffdcf85 100644 --- a/src/app/settings/EmailPage.tsx +++ b/src/app/settings/EmailPage.tsx @@ -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(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(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 ( -
+
- ); + ) } return ( -
-

- {t("settings.email_page.title")} -

-

- {t("settings.email_page.subtitle")} -

- -
-
- ); -} \ No newline at end of file + ) +} diff --git a/src/app/settings/MyAccountPage.tsx b/src/app/settings/MyAccountPage.tsx index ae32bba..7453bf8 100644 --- a/src/app/settings/MyAccountPage.tsx +++ b/src/app/settings/MyAccountPage.tsx @@ -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(cachedProfile); - const [loading, setLoading] = useState(!cachedProfile); - const [saving, setSaving] = useState(false); - const [uploading, setUploading] = useState(false); - const fileInputRef = useRef(null); + const { profile: cachedProfile, setProfile: setCachedProfile } = + useSettingsDataCache() + const [profile, setProfile] = useState(cachedProfile) + const [loading, setLoading] = useState(!cachedProfile) + const [saving, setSaving] = useState(false) + const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(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) => { - 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 (
- +
- ); + ) } return ( -
-

- {t("settings.my_account.title")} -

-

- {t("settings.my_account.subtitle")} -

- - {/* Avatar Section */} -
-