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:
parent
16739d3cf8
commit
86ab2d2f85
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user