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 { useState } from "react"
|
||||||
import type { AccessKeyResponse } from "@/client/model";
|
import { format } from "date-fns"
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { Input } from "@/components/ui/input";
|
AlertCircle,
|
||||||
import { Label } from "@/components/ui/label";
|
Check,
|
||||||
import { Plus, Trash2, Key, Copy, Check } from "lucide-react";
|
Copy,
|
||||||
import { format } from "date-fns";
|
Key,
|
||||||
import { useAccessKeysQuery, useCreateAccessKeyMutation, useDeleteAccessKeyMutation } from "@/hooks/useAccessKeysQuery";
|
Loader2,
|
||||||
import { t } from "@/i18n/T";
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import type { AccessKeyResponse } from "@/client/model"
|
||||||
|
import {
|
||||||
|
useAccessKeysQuery,
|
||||||
|
useCreateAccessKeyMutation,
|
||||||
|
useDeleteAccessKeyMutation,
|
||||||
|
} from "@/hooks/useAccessKeysQuery"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
|
||||||
export function AccessKeysPage() {
|
export function AccessKeysPage() {
|
||||||
const { data: keys = [], isLoading } = useAccessKeysQuery();
|
const { data: keys = [], isLoading } = useAccessKeysQuery()
|
||||||
const createMutation = useCreateAccessKeyMutation();
|
const createMutation = useCreateAccessKeyMutation()
|
||||||
const deleteMutation = useDeleteAccessKeyMutation();
|
const deleteMutation = useDeleteAccessKeyMutation()
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [addForm, setAddForm] = useState({ name: "" });
|
const [addForm, setAddForm] = useState({ name: "" })
|
||||||
const [newKey, setNewKey] = useState<string | null>(null);
|
const [newKey, setNewKey] = useState<string | null>(null)
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false)
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text)
|
||||||
setCopied(true);
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!addForm.name.trim()) {
|
if (!addForm.name.trim()) {
|
||||||
setMessage({ type: "error", text: t("settings.access_keys.messages.name_required") });
|
setMessage({
|
||||||
return;
|
type: "error",
|
||||||
|
text: t("settings.access_keys.messages.name_required"),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await createMutation.mutateAsync({ name: addForm.name.trim(), scopes: [] });
|
const res = await createMutation.mutateAsync({
|
||||||
// The API returns the new key only once in the response
|
name: addForm.name.trim(),
|
||||||
|
scopes: [],
|
||||||
|
})
|
||||||
if (res?.access_key) {
|
if (res?.access_key) {
|
||||||
setNewKey(res.access_key);
|
setNewKey(res.access_key)
|
||||||
}
|
}
|
||||||
setMessage({ type: "success", text: t("settings.access_keys.messages.created_success") });
|
setMessage({
|
||||||
setShowAdd(false);
|
type: "success",
|
||||||
setAddForm({ name: "" });
|
text: t("settings.access_keys.messages.created_success"),
|
||||||
|
})
|
||||||
|
setShowAdd(false)
|
||||||
|
setAddForm({ name: "" })
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.access_keys.messages.create_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.access_keys.messages.create_failed"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = async (keyId: number) => {
|
const handleDelete = async (keyId: number) => {
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(keyId);
|
await deleteMutation.mutateAsync(keyId)
|
||||||
setMessage({ type: "success", text: t("settings.access_keys.messages.delete_success") });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.access_keys.messages.delete_success"),
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.access_keys.messages.delete_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.access_keys.messages.delete_failed"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div
|
<Loader2
|
||||||
className="w-6 h-6 rounded-full border-2 border-muted border-t-accent animate-spin"
|
className="size-6 animate-spin"
|
||||||
style={{ borderColor: "var(--border-default)", borderTopColor: "var(--accent)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-[20px] font-bold" style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.title")}</h1>
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<h1
|
||||||
|
className="text-[20px] font-bold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{t("settings.access_keys.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("settings.access_keys.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAdd(true);
|
setShowAdd(true)
|
||||||
setNewKey(null);
|
setNewKey(null)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
<Plus className="mr-2 size-4" />
|
||||||
{t("settings.access_keys.generate_button")}
|
{t("settings.access_keys.generate_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] mb-6" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{t("settings.access_keys.description")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* New Token Display */}
|
{message && !newKey && (
|
||||||
{newKey && (
|
<Alert
|
||||||
<div className="mb-6 p-4 rounded-lg" style={{ backgroundColor: "var(--success-alpha10)", border: "1px solid var(--success)" }}>
|
variant={message.type === "error" ? "destructive" : undefined}
|
||||||
<h3 className="text-[14px] font-semibold mb-2" style={{ color: "var(--success)" }}>
|
className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
|
||||||
{t("settings.access_keys.copy_warning")}
|
>
|
||||||
</h3>
|
<AlertCircle className="size-4" />
|
||||||
<div className="flex items-center gap-2">
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
<div className="flex-1 font-mono text-sm p-2 rounded border truncate" style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--success)", color: "var(--text-primary)" }}>
|
</Alert>
|
||||||
{newKey}
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newKey)}>
|
|
||||||
{copied ? <Check className="w-4 h-4" style={{ color: "var(--success)" }} /> : <Copy className="w-4 h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Form */}
|
{newKey && (
|
||||||
{showAdd && !newKey && (
|
<Card
|
||||||
<div className="mb-6 p-4 rounded-lg border" style={{ borderColor: "var(--accent)", backgroundColor: "var(--surface-elevated)" }}>
|
className="bg-[var(--success-alpha10)]"
|
||||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.generate_button")}</h3>
|
style={{
|
||||||
<div className="space-y-4">
|
borderColor: "color-mix(in srgb, var(--success) 30%, transparent)",
|
||||||
<div>
|
}}
|
||||||
<Label className="text-[12px] font-semibold uppercase mb-1 block" style={{ color: "var(--text-muted)" }}>{t("settings.access_keys.token_name")}</Label>
|
>
|
||||||
<Input
|
<CardHeader className="gap-2">
|
||||||
value={addForm.name}
|
<CardTitle className="text-[15px] text-[var(--success)]">
|
||||||
onChange={(e) => setAddForm({ name: e.target.value })}
|
{t("settings.access_keys.copy_warning")}
|
||||||
placeholder="e.g. CLI Token"
|
</CardTitle>
|
||||||
style={{ backgroundColor: "var(--input-bg)", border: "none" }}
|
<CardDescription>
|
||||||
/>
|
This token is shown once. Copy it now and store it securely.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
className="rounded-xl border bg-[var(--surface-ground)] p-3 font-mono text-[13px] text-[var(--text-primary)]"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--success) 30%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{newKey}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(newKey)}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="mr-2 size-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied" : "Copy token"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && !newKey && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="text-[15px]">
|
||||||
|
{t("settings.access_keys.generate_button")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a token for scripts, CLI tools, or local integrations.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="access-key-name">
|
||||||
|
<FieldTitle>
|
||||||
|
{t("settings.access_keys.token_name")}
|
||||||
|
</FieldTitle>
|
||||||
|
<FieldDescription>
|
||||||
|
Give the token a recognizable name.
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="access-key-name"
|
||||||
|
value={addForm.name}
|
||||||
|
onChange={(e) => setAddForm({ name: e.target.value })}
|
||||||
|
placeholder="e.g. CLI Token"
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAdd(false)
|
||||||
|
setAddForm({ name: "" })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.actions.cancel")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
|
||||||
>
|
>
|
||||||
{createMutation.isPending && (
|
{createMutation.isPending && (
|
||||||
<div
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
className="w-3.5 h-3.5 mr-1.5 rounded-full border-2 border-accent-fg/30 border-t-accent-fg animate-spin"
|
|
||||||
style={{ borderColor: "color-mix(in srgb, var(--accent-fg) 30%, transparent)", borderTopColor: "var(--accent-fg)" }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{t("settings.access_keys.generate_token")}
|
{t("settings.access_keys.generate_token")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setShowAdd(false)} variant="ghost" size="sm" style={{ color: "var(--text-secondary)" }}>{t("common.actions.cancel")}</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Keys List */}
|
|
||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<div className="text-center py-12 rounded-lg border" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
|
<Empty className="border border-dashed border-[var(--border-subtle)] bg-[var(--surface-secondary)]/70 py-12">
|
||||||
<Key className="w-10 h-10 mx-auto mb-3 opacity-20" style={{ color: "var(--text-muted)" }} />
|
<EmptyHeader>
|
||||||
<p style={{ color: "var(--text-primary)" }}>{t("settings.access_keys.empty")}</p>
|
<EmptyMedia variant="icon">
|
||||||
</div>
|
<Key />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>{t("settings.access_keys.empty")}</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Access tokens let you automate against the API without using your
|
||||||
|
password.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{keys.map((key: AccessKeyResponse) => (
|
{keys.map((key: AccessKeyResponse) => (
|
||||||
<div
|
<Card key={key.id} size="sm">
|
||||||
key={key.id}
|
<CardContent className="flex flex-col gap-4 pt-4">
|
||||||
className="p-4 rounded-lg border flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
|
<div className="flex items-start justify-between gap-3">
|
||||||
>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0">
|
<p
|
||||||
<p className="text-[14px] font-semibold truncate" style={{ color: "var(--text-primary)" }}>{key.name}</p>
|
className="truncate text-[14px] font-semibold"
|
||||||
<div className="flex items-center gap-3 mt-1 text-[12px]" style={{ color: "var(--text-muted)" }}>
|
style={{ color: "var(--text-primary)" }}
|
||||||
<span>{t("settings.access_keys.created_on")} {format(new Date(key.created_at), "MMM d, yyyy")}</span>
|
>
|
||||||
{key.expires_at && (
|
{key.name}
|
||||||
<>
|
</p>
|
||||||
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: "var(--text-muted)" }} />
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-[12px]">
|
||||||
<span>{t("settings.access_keys.expires")} {format(new Date(key.expires_at), "MMM d, yyyy")}</span>
|
<Badge
|
||||||
</>
|
variant="outline"
|
||||||
)}
|
className="rounded-full px-2.5 py-1"
|
||||||
|
>
|
||||||
|
{t("settings.access_keys.created_on")}{" "}
|
||||||
|
{format(new Date(key.created_at), "MMM d, yyyy")}
|
||||||
|
</Badge>
|
||||||
|
{key.expires_at && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-full px-2.5 py-1"
|
||||||
|
>
|
||||||
|
{t("settings.access_keys.expires")}{" "}
|
||||||
|
{format(new Date(key.expires_at), "MMM d, yyyy")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(key.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Button
|
<Separator />
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
<div className="text-[12px] text-muted-foreground">
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
Key #{key.id} will be removed immediately after deletion.
|
||||||
onClick={() => handleDelete(key.id)}
|
</div>
|
||||||
>
|
</CardContent>
|
||||||
<Trash2 className="w-4 h-4" />
|
</Card>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message && !newKey && (
|
|
||||||
<div className={`mt-4 text-[13px]`} style={{ color: message.type === "success" ? "var(--success)" : "var(--destructive)" }}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,311 +1,371 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
import { getPreferences, updatePreferences } from "@/client/api";
|
import { Loader2, Palette, Sparkles } from "lucide-react"
|
||||||
import type { PreferencesResponse } from "@/client/model";
|
import { getPreferences, updatePreferences } from "@/client/api"
|
||||||
import { Button } from "@/components/ui/button";
|
import type { PreferencesResponse } from "@/client/model"
|
||||||
import { Label } from "@/components/ui/label";
|
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select"
|
||||||
import { Loader2, Palette } from "lucide-react";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { SETTINGS_PAGE } from "@/css/app/styles";
|
import { ThemeCustomization } from "@/components/theme/ThemeCustomization"
|
||||||
import { ThemeCustomization } from "@/components/theme/ThemeCustomization";
|
import { ThemePresetSelector } from "@/components/theme/ThemePresetSelector"
|
||||||
import { ThemePresetSelector } from "@/components/theme/ThemePresetSelector";
|
import { t } from "@/i18n/T"
|
||||||
import { t } from "@/i18n/T";
|
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ value: "zh-CN", label: "简体中文" },
|
{ value: "zh-CN", label: "简体中文" },
|
||||||
{ value: "zh-TW", label: "繁體中文" },
|
{ value: "zh-TW", label: "繁體中文" },
|
||||||
{ value: "en", label: "English" },
|
{ value: "en", label: "English" },
|
||||||
{ value: "ja", label: "日本語" },
|
{ value: "ja", label: "日本語" },
|
||||||
];
|
]
|
||||||
|
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ value: "dark", label: t("settings.appearance_page.dark") },
|
{ value: "dark", label: t("settings.appearance_page.dark") },
|
||||||
{ value: "light", label: t("settings.appearance_page.light") },
|
{ value: "light", label: t("settings.appearance_page.light") },
|
||||||
{ value: "system", label: t("settings.appearance_page.system") },
|
{ value: "system", label: t("settings.appearance_page.system") },
|
||||||
];
|
]
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
{ value: "Asia/Shanghai", label: t("settings.appearance.timezone_asia_shanghai") || "Asia/Shanghai (UTC+8)" },
|
{
|
||||||
{ value: "Asia/Tokyo", label: t("settings.appearance.timezone_asia_tokyo") || "Asia/Tokyo (UTC+9)" },
|
value: "Asia/Shanghai",
|
||||||
{ value: "America/New_York", label: t("settings.appearance.timezone_america_ny") || "America/New_York (UTC-5)" },
|
label:
|
||||||
{ value: "America/Los_Angeles", label: t("settings.appearance.timezone_america_la") || "America/Los_Angeles (UTC-8)" },
|
t("settings.appearance.timezone_asia_shanghai") ||
|
||||||
{ value: "Europe/London", label: t("settings.appearance.timezone_europe_london") || "Europe/London (UTC+0)" },
|
"Asia/Shanghai (UTC+8)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Asia/Tokyo",
|
||||||
|
label: t("settings.appearance.timezone_asia_tokyo") || "Asia/Tokyo (UTC+9)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "America/New_York",
|
||||||
|
label:
|
||||||
|
t("settings.appearance.timezone_america_ny") ||
|
||||||
|
"America/New_York (UTC-5)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "America/Los_Angeles",
|
||||||
|
label:
|
||||||
|
t("settings.appearance.timezone_america_la") ||
|
||||||
|
"America/Los_Angeles (UTC-8)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Europe/London",
|
||||||
|
label:
|
||||||
|
t("settings.appearance.timezone_europe_london") ||
|
||||||
|
"Europe/London (UTC+0)",
|
||||||
|
},
|
||||||
{ value: "UTC", label: "UTC" },
|
{ value: "UTC", label: "UTC" },
|
||||||
];
|
]
|
||||||
|
|
||||||
const SelectField = ({
|
function SelectField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
description,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string
|
||||||
value: string;
|
value: string
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void
|
||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string }[]
|
||||||
}) => (
|
description: string
|
||||||
<div>
|
}) {
|
||||||
<Label
|
return (
|
||||||
className={SETTINGS_PAGE.formLabel}
|
<Field>
|
||||||
style={{ color: "var(--text-muted)" }}
|
<FieldLabel>
|
||||||
>
|
<FieldTitle>{label}</FieldTitle>
|
||||||
{label}
|
<FieldDescription>{description}</FieldDescription>
|
||||||
</Label>
|
</FieldLabel>
|
||||||
<Select value={value} onValueChange={onChange}>
|
<FieldContent>
|
||||||
<SelectTrigger
|
<Select value={value} onValueChange={onChange}>
|
||||||
className="w-[260px] text-[14px]"
|
<SelectTrigger>
|
||||||
style={{
|
<SelectValue />
|
||||||
backgroundColor: "var(--surface-elevated)",
|
</SelectTrigger>
|
||||||
borderColor: "var(--border-default)",
|
<SelectContent>
|
||||||
color: "var(--text-primary)",
|
{options.map((o) => (
|
||||||
}}
|
<SelectItem key={o.value} value={o.value}>
|
||||||
>
|
{o.label}
|
||||||
<SelectValue />
|
</SelectItem>
|
||||||
</SelectTrigger>
|
))}
|
||||||
<SelectContent
|
</SelectContent>
|
||||||
style={{
|
</Select>
|
||||||
backgroundColor: "var(--surface-elevated)",
|
</FieldContent>
|
||||||
borderColor: "var(--border-default)",
|
</Field>
|
||||||
color: "var(--text-primary)",
|
)
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
{options.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export function AppearancePage() {
|
export function AppearancePage() {
|
||||||
const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache();
|
const { preferences: cachedPrefs, setPreferences: setCachedPrefs } =
|
||||||
const [, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs);
|
useSettingsDataCache()
|
||||||
const [loading, setLoading] = useState(!cachedPrefs);
|
const [, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs)
|
||||||
const [saving, setSaving] = useState(false);
|
const [loading, setLoading] = useState(!cachedPrefs)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
language: cachedPrefs?.language ?? "zh-CN",
|
language: cachedPrefs?.language ?? "zh-CN",
|
||||||
theme: cachedPrefs?.theme ?? "dark",
|
theme: cachedPrefs?.theme ?? "dark",
|
||||||
timezone: cachedPrefs?.timezone ?? "Asia/Shanghai",
|
timezone: cachedPrefs?.timezone ?? "Asia/Shanghai",
|
||||||
});
|
})
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedPrefs) return;
|
if (cachedPrefs) return
|
||||||
(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getPreferences();
|
const res = await getPreferences()
|
||||||
const d = res.data.data!;
|
const data = res.data.data!
|
||||||
setPrefs(d);
|
setPrefs(data)
|
||||||
setCachedPrefs(d);
|
setCachedPrefs(data)
|
||||||
setForm({
|
setForm({
|
||||||
language: d.language,
|
language: data.language,
|
||||||
theme: d.theme,
|
theme: data.theme,
|
||||||
timezone: d.timezone,
|
timezone: data.timezone,
|
||||||
});
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.appearance_page.load_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.appearance_page.load_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})();
|
})()
|
||||||
}, [cachedPrefs, setCachedPrefs]);
|
}, [cachedPrefs, setCachedPrefs])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
language: form.language,
|
language: form.language,
|
||||||
theme: form.theme,
|
theme: form.theme,
|
||||||
timezone: form.timezone,
|
timezone: form.timezone,
|
||||||
});
|
})
|
||||||
setMessage({ type: "success", text: t("settings.appearance_page.save_success") });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.appearance_page.save_success"),
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.appearance_page.save_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.appearance_page.save_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={SETTINGS_PAGE.loadingState}>
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="w-6 h-6 animate-spin"
|
className="size-6 animate-spin"
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const themes = [
|
|
||||||
{ value: "dark", label: t("settings.appearance_page.dark") },
|
|
||||||
{ value: "light", label: t("settings.appearance_page.light") },
|
|
||||||
{ value: "system", label: t("settings.appearance_page.system") },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className={SETTINGS_PAGE.pageHeader} style={{ color: "var(--text-primary)" }}>
|
<div className="flex flex-col gap-2">
|
||||||
{t("settings.appearance_page.title")}
|
<h1
|
||||||
</h1>
|
className="text-[20px] font-bold"
|
||||||
<p className={SETTINGS_PAGE.pageSubtitle} style={{ color: "var(--text-muted)" }}>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{t("settings.appearance_page.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Tabs defaultValue="preset" className="mt-6">
|
|
||||||
<TabsList
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
border: "1px solid var(--border-default)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TabsTrigger
|
{t("settings.appearance_page.title")}
|
||||||
value="preset"
|
</h1>
|
||||||
style={{ color: "var(--text-secondary)" }}
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
>
|
{t("settings.appearance_page.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="preset" className="flex flex-col gap-4">
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-1">
|
||||||
|
<TabsTrigger value="preset" className="rounded-xl text-[13px]">
|
||||||
{t("settings.appearance_page.theme_scheme")}
|
{t("settings.appearance_page.theme_scheme")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger value="basic" className="rounded-xl text-[13px]">
|
||||||
value="basic"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{t("settings.appearance_page.basic_settings") || "Basic Settings"}
|
{t("settings.appearance_page.basic_settings") || "Basic Settings"}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger value="custom" className="rounded-xl text-[13px]">
|
||||||
value="custom"
|
<Palette data-icon="inline-start" />
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<Palette className="w-4 h-4 mr-1.5" />
|
|
||||||
{t("settings.appearance_page.custom") || "Custom"}
|
{t("settings.appearance_page.custom") || "Custom"}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="preset" className="mt-4">
|
<TabsContent value="preset" className="mt-0">
|
||||||
<h3 className="text-[14px] font-semibold mb-4" style={{ color: "var(--text-primary)" }}>
|
<Card>
|
||||||
{t("settings.appearance_page.select_theme_scheme")}
|
<CardHeader className="gap-2">
|
||||||
</h3>
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
<ThemePresetSelector />
|
<Sparkles className="size-4" />
|
||||||
|
{t("settings.appearance_page.select_theme_scheme")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose a preset that matches your preferred visual tone.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ThemePresetSelector />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="basic" className="mt-4">
|
<TabsContent value="basic" className="mt-0">
|
||||||
<div className={SETTINGS_PAGE.formSection}>
|
<Card>
|
||||||
<div>
|
<CardHeader className="gap-2">
|
||||||
<h3
|
<CardTitle className="text-[15px]">
|
||||||
className="text-[14px] font-semibold mb-4"
|
{t("settings.appearance_page.basic_settings") ||
|
||||||
style={{ color: "var(--text-primary)" }}
|
"Basic Settings"}
|
||||||
>
|
</CardTitle>
|
||||||
{t("settings.appearance_page.theme")}
|
<CardDescription>
|
||||||
</h3>
|
Tune the general locale and theme behavior for the app.
|
||||||
<div className="flex gap-3">
|
</CardDescription>
|
||||||
{themes.map((themeOption) => (
|
</CardHeader>
|
||||||
<button
|
<CardContent className="flex flex-col gap-6">
|
||||||
key={themeOption.value}
|
<FieldGroup>
|
||||||
onClick={() => setForm((f) => ({ ...f, theme: themeOption.value }))}
|
<Field>
|
||||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all"
|
<FieldLabel>
|
||||||
style={{
|
<FieldTitle>
|
||||||
width: "100px",
|
{t("settings.appearance_page.theme")}
|
||||||
borderColor:
|
</FieldTitle>
|
||||||
form.theme === themeOption.value
|
<FieldDescription>
|
||||||
? "var(--accent)"
|
Select the default color mode for the interface.
|
||||||
: "var(--border-default)",
|
</FieldDescription>
|
||||||
backgroundColor:
|
</FieldLabel>
|
||||||
form.theme === themeOption.value
|
<FieldContent>
|
||||||
? "var(--hover-bg-strong)"
|
<ToggleGroup
|
||||||
: "var(--surface-elevated)",
|
type="single"
|
||||||
}}
|
value={form.theme}
|
||||||
>
|
onValueChange={(v) => {
|
||||||
<div
|
if (!v) return
|
||||||
className="w-10 h-10 rounded-full flex items-center justify-center text-[11px] font-bold"
|
setForm((f) => ({ ...f, theme: v }))
|
||||||
style={{
|
}}
|
||||||
backgroundColor:
|
className="grid w-full grid-cols-3"
|
||||||
themeOption.value === "dark"
|
>
|
||||||
? "#1E1F22"
|
{THEMES.map((themeOption) => (
|
||||||
: themeOption.value === "light"
|
<ToggleGroupItem
|
||||||
? "#F2F3F5"
|
key={themeOption.value}
|
||||||
: "linear-gradient(135deg, #1E1F22 50%, #F2F3F5 50%)",
|
value={themeOption.value}
|
||||||
color:
|
variant="outline"
|
||||||
themeOption.value === "dark"
|
className="flex h-auto flex-col gap-3 rounded-2xl px-4 py-4 text-center"
|
||||||
? "#DBDEE1"
|
>
|
||||||
: themeOption.value === "light"
|
<div
|
||||||
? "#313338"
|
className="flex size-10 items-center justify-center rounded-full text-[11px] font-semibold"
|
||||||
: "#5865F2",
|
style={{
|
||||||
}}
|
background:
|
||||||
>
|
themeOption.value === "dark"
|
||||||
{themeOption.value === "dark" ? "D" : themeOption.value === "light" ? "L" : "S"}
|
? "#1E1F22"
|
||||||
</div>
|
: themeOption.value === "light"
|
||||||
<span
|
? "#F2F3F5"
|
||||||
className="text-[12px]"
|
: "linear-gradient(135deg, #1E1F22 50%, #F2F3F5 50%)",
|
||||||
style={{ color: "var(--text-primary)" }}
|
color:
|
||||||
>
|
themeOption.value === "dark"
|
||||||
{themeOption.label}
|
? "#DBDEE1"
|
||||||
</span>
|
: themeOption.value === "light"
|
||||||
</button>
|
? "#313338"
|
||||||
))}
|
: "#5865F2",
|
||||||
</div>
|
}}
|
||||||
</div>
|
>
|
||||||
|
{themeOption.value === "dark"
|
||||||
|
? "D"
|
||||||
|
: themeOption.value === "light"
|
||||||
|
? "L"
|
||||||
|
: "S"}
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px]">
|
||||||
|
{themeOption.label}
|
||||||
|
</span>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
label={t("settings.appearance_page.language")}
|
label={t("settings.appearance_page.language")}
|
||||||
value={form.language}
|
description={t("settings.appearance_page.language")}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, language: v }))}
|
value={form.language}
|
||||||
options={LANGUAGES}
|
onChange={(v) => setForm((f) => ({ ...f, language: v }))}
|
||||||
/>
|
options={LANGUAGES}
|
||||||
|
/>
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
label={t("settings.appearance_page.timezone")}
|
label={t("settings.appearance_page.timezone")}
|
||||||
value={form.timezone}
|
description={t("settings.appearance_page.timezone")}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, timezone: v }))}
|
value={form.timezone}
|
||||||
options={TIMEZONES}
|
onChange={(v) => setForm((f) => ({ ...f, timezone: v }))}
|
||||||
/>
|
options={TIMEZONES}
|
||||||
</div>
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="custom" className="mt-0">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="text-[15px]">
|
||||||
|
{t("settings.appearance_page.custom") || "Custom"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Adjust token-level theme variables and fine-grained styling.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ThemeCustomization />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<Alert
|
||||||
className={SETTINGS_PAGE.message}
|
variant={message.type === "error" ? "destructive" : undefined}
|
||||||
style={{
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
color:
|
|
||||||
message.type === "success"
|
|
||||||
? "var(--success)"
|
|
||||||
: "var(--destructive)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{message.text}
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.buttonRowSimple}>
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Badge variant="outline" className="rounded-full px-3 py-1 text-[11px]">
|
||||||
|
{form.theme}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{
|
className="min-w-28"
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />}
|
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
{t("settings.appearance_page.save_button")}
|
{t("settings.appearance_page.save_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="custom" className="mt-4">
|
|
||||||
<ThemeCustomization />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,140 +1,308 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { userBilling, userBillingErrors, userBillingHistory } from "@/client/api";
|
import {
|
||||||
import type { UserBillingResponse, UserBillingErrorsResponse, UserBillingHistoryResponse } from "@/client/model";
|
userBilling,
|
||||||
import { Loader2, DollarSign, TrendingUp, AlertTriangle, CreditCard } from "lucide-react";
|
userBillingErrors,
|
||||||
import { t } from "@/i18n/T";
|
userBillingHistory,
|
||||||
|
} from "@/client/api"
|
||||||
|
import type {
|
||||||
|
UserBillingResponse,
|
||||||
|
UserBillingErrorsResponse,
|
||||||
|
UserBillingHistoryResponse,
|
||||||
|
} from "@/client/model"
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
export function BillingPage() {
|
export function BillingPage() {
|
||||||
const { data: billing, isLoading: bLoading } = useQuery({
|
const { data: billing, isLoading: bLoading } = useQuery({
|
||||||
queryKey: ["user-billing"],
|
queryKey: ["user-billing"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await userBilling();
|
const res = await userBilling()
|
||||||
return res.data?.data as UserBillingResponse;
|
return res.data?.data as UserBillingResponse
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: errors } = useQuery({
|
const { data: errors } = useQuery({
|
||||||
queryKey: ["user-billing-errors"],
|
queryKey: ["user-billing-errors"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await userBillingErrors();
|
const res = await userBillingErrors()
|
||||||
return res.data?.data as UserBillingErrorsResponse;
|
return res.data?.data as UserBillingErrorsResponse
|
||||||
},
|
},
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
});
|
})
|
||||||
|
|
||||||
const { data: history, isLoading: hLoading } = useQuery({
|
const { data: history, isLoading: hLoading } = useQuery({
|
||||||
queryKey: ["user-billing-history"],
|
queryKey: ["user-billing-history"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await userBillingHistory();
|
const res = await userBillingHistory()
|
||||||
return res.data?.data as UserBillingHistoryResponse;
|
return res.data?.data as UserBillingHistoryResponse
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (bLoading) return <div className="flex justify-center py-10"><Loader2 className="w-5 h-5 animate-spin" style={{ color: "var(--text-muted)" }} /></div>;
|
if (bLoading) {
|
||||||
if (!billing) return <div className="text-center py-10" style={{ color: "var(--destructive)" }}>{t("settings.billing.load_failed")}</div>;
|
return (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2
|
||||||
|
className="size-5 animate-spin"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const hasErrors = !!errors?.list && errors.list.length > 0;
|
if (!billing) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
variant="destructive"
|
||||||
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
<AlertDescription>{t("settings.billing.load_failed")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = !!errors?.list && errors.list.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* Billing Errors Banner */}
|
|
||||||
{hasErrors && errors?.list && (
|
{hasErrors && errors?.list && (
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--destructive-alpha10)", border: "1px solid var(--destructive)" }}>
|
<Alert
|
||||||
<div className="flex items-center gap-2 mb-3">
|
variant="destructive"
|
||||||
<AlertTriangle className="w-4 h-4" style={{ color: "var(--destructive)" }} />
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
<h2 className="text-[14px] font-semibold" style={{ color: "var(--destructive)" }}>{t("settings.billing.billing_errors")}</h2>
|
>
|
||||||
</div>
|
<AlertTriangle className="size-4" />
|
||||||
<div className="space-y-2">
|
<AlertTitle>{t("settings.billing.billing_errors")}</AlertTitle>
|
||||||
{errors!.list.map(err => (
|
<AlertDescription className="space-y-2">
|
||||||
<div key={err.id} className="p-3 rounded-md" style={{ backgroundColor: "var(--surface-ground)" }}>
|
{errors.list.map((err) => (
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div
|
||||||
<span className="text-[12px] font-semibold" style={{ color: "var(--destructive)" }}>
|
key={err.id}
|
||||||
{err.error_type === "insufficient_balance" ? t("settings.billing.insufficient_balance") : err.error_type}
|
className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3">
|
||||||
|
<span
|
||||||
|
className="text-[12px] font-semibold"
|
||||||
|
style={{ color: "var(--destructive)" }}
|
||||||
|
>
|
||||||
|
{err.error_type === "insufficient_balance"
|
||||||
|
? t("settings.billing.insufficient_balance")
|
||||||
|
: err.error_type}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
<span
|
||||||
|
className="text-[11px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
{new Date(err.created_at).toLocaleString()}
|
{new Date(err.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px]" style={{ color: "var(--text-primary)" }}>{err.message}</p>
|
<p
|
||||||
|
className="text-[13px]"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{err.message}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</AlertDescription>
|
||||||
</section>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current Balance */}
|
<Card>
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
<CardHeader className="gap-2">
|
||||||
<h2 className="text-[14px] font-semibold mb-4" style={{ color: "var(--text-primary)" }}>{t("settings.billing.personal_billing")}</h2>
|
<CardTitle className="text-[15px]">
|
||||||
|
{t("settings.billing.personal_billing")}
|
||||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
</CardTitle>
|
||||||
<div className="p-4 rounded-md" style={{ backgroundColor: "var(--surface-ground)" }}>
|
<CardDescription>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
{billing.is_pro ? "Pro plan billing" : "Free plan billing"}
|
||||||
<DollarSign className="w-4 h-4" style={{ color: "var(--success)" }} />
|
</CardDescription>
|
||||||
<span className="text-[11px] uppercase font-semibold" style={{ color: "var(--text-muted)" }}>{t("settings.billing.balance")}</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="grid gap-4">
|
||||||
<p className="text-[24px] font-bold" style={{ color: "var(--text-primary)" }}>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{billing.currency} {billing.balance.toFixed(2)}
|
<div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4">
|
||||||
</p>
|
<div className="mb-1 flex items-center gap-2">
|
||||||
</div>
|
<DollarSign
|
||||||
{billing.is_pro && (
|
className="size-4"
|
||||||
<div className="p-4 rounded-md" style={{ backgroundColor: "var(--surface-ground)" }}>
|
style={{ color: "var(--success)" }}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
/>
|
||||||
<TrendingUp className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
<span
|
||||||
<span className="text-[11px] uppercase font-semibold" style={{ color: "var(--text-muted)" }}>{t("settings.billing.monthly_quota")}</span>
|
className="text-[11px] font-semibold uppercase"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("settings.billing.balance")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[24px] font-bold" style={{ color: "var(--text-primary)" }}>
|
<p
|
||||||
|
className="text-[24px] font-bold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{billing.currency} {billing.balance.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<TrendingUp
|
||||||
|
className="size-4"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-semibold uppercase"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("settings.billing.monthly_quota")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-[24px] font-bold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
{billing.currency} {billing.monthly_quota.toFixed(2)}
|
{billing.currency} {billing.monthly_quota.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billing.is_pro && billing.monthly_quota > 0 && (
|
||||||
|
<div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-4">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3 text-[12px]">
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("settings.billing.monthly_usage")}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
{billing.currency} {billing.month_used.toFixed(2)} /{" "}
|
||||||
|
{billing.currency} {billing.monthly_quota.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (billing.month_used / billing.monthly_quota) * 100)}%`,
|
||||||
|
backgroundColor:
|
||||||
|
billing.month_used / billing.monthly_quota > 0.9
|
||||||
|
? "var(--destructive)"
|
||||||
|
: "var(--success)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{billing.is_pro && billing.monthly_quota > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="mb-2">
|
<Badge
|
||||||
<div className="flex justify-between text-[12px] mb-1">
|
variant="outline"
|
||||||
<span style={{ color: "var(--text-muted)" }}>{t("settings.billing.monthly_usage")}</span>
|
className="rounded-full px-2.5 py-1 text-[11px]"
|
||||||
<span style={{ color: "var(--text-primary)" }}>{billing.currency} {billing.month_used.toFixed(2)} / {billing.currency} {billing.monthly_quota.toFixed(2)}</span>
|
>
|
||||||
</div>
|
<CreditCard className="size-3.5" />
|
||||||
<div className="w-full h-2 rounded-full" style={{ backgroundColor: "var(--border-default)" }}>
|
{billing.currency} {billing.is_pro ? "Pro" : "Free"}
|
||||||
{billing.monthly_quota > 0 && (
|
</Badge>
|
||||||
<div className="h-2 rounded-full transition-all" style={{ width: `${Math.min(100, (billing.month_used / billing.monthly_quota) * 100)}%`, backgroundColor: billing.month_used / billing.monthly_quota > 0.9 ? "var(--destructive)" : "var(--success)" }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-[12px]" style={{ color: "var(--text-muted)" }}>
|
<Card>
|
||||||
<div className="flex items-center gap-1">
|
<CardHeader className="gap-2">
|
||||||
<CreditCard className="w-3 h-3" />
|
<CardTitle className="text-[15px]">
|
||||||
{billing.currency} {billing.is_pro ? "Pro" : "Free"}
|
{t("settings.billing.history")}{" "}
|
||||||
</div>
|
{history ? `(${history.total})` : ""}
|
||||||
</div>
|
</CardTitle>
|
||||||
</section>
|
<CardDescription>Recent billing activity</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
{/* Billing History */}
|
<CardContent>
|
||||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
{hLoading ? (
|
||||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>
|
<div className="flex justify-center py-6">
|
||||||
{t("settings.billing.history")} {history ? `(${history.total})` : ""}
|
<Loader2 className="size-4 animate-spin" />
|
||||||
</h2>
|
</div>
|
||||||
{hLoading ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
) : !history?.list?.length ? (
|
||||||
!history?.list?.length ? <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>{t("settings.billing.no_history")}</p> :
|
<Empty className="border-0 bg-transparent py-10">
|
||||||
<div className="overflow-hidden rounded-lg" style={{ border: "1px solid var(--border-default)" }}>
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<CreditCard />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>{t("settings.billing.no_history")}</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
No billing activity has been recorded yet.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-[var(--border-subtle)]">
|
||||||
<table className="w-full text-[13px]">
|
<table className="w-full text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: "var(--surface-ground)", borderBottom: "1px solid var(--border-default)" }}>
|
<tr className="bg-[var(--surface-ground)]">
|
||||||
<th className="text-left px-3 py-2" style={{ color: "var(--text-muted)" }}>{t("settings.billing.date")}</th>
|
<th
|
||||||
<th className="text-left px-3 py-2" style={{ color: "var(--text-muted)" }}>{t("settings.billing.reason")}</th>
|
className="px-3 py-2 text-left"
|
||||||
<th className="text-right px-3 py-2" style={{ color: "var(--text-muted)" }}>{t("settings.billing.amount")}</th>
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("settings.billing.date")}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-3 py-2 text-left"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("settings.billing.reason")}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-3 py-2 text-right"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("settings.billing.amount")}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{history.list.map(item => (
|
{history.list.map((item, index) => (
|
||||||
<tr key={item.uid} style={{ borderBottom: "1px solid var(--border-subtle)" }}>
|
<tr
|
||||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{new Date(item.created_at).toLocaleDateString()}</td>
|
key={item.uid}
|
||||||
<td className="px-3 py-2" style={{ color: "var(--text-primary)" }}>{item.reason}</td>
|
className={
|
||||||
<td className="px-3 py-2 text-right" style={{ color: Number(item.amount) < 0 ? "var(--destructive)" : "var(--success)" }}>
|
index < history.list.length - 1
|
||||||
|
? "border-b border-[var(--border-subtle)]"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{item.reason}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2 text-right"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
Number(item.amount) < 0
|
||||||
|
? "var(--destructive)"
|
||||||
|
: "var(--success)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{billing.currency} {Number(item.amount).toFixed(4)}
|
{billing.currency} {Number(item.amount).toFixed(4)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -142,8 +310,9 @@ export function BillingPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,184 +1,227 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
import { apiEmailGet, apiEmailChange } from "@/client/api";
|
import { AlertCircle, CheckCircle2, Loader2, Mail } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button";
|
import { apiEmailChange, apiEmailGet } from "@/client/api"
|
||||||
import { Input } from "@/components/ui/input";
|
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { Loader2 } from "lucide-react";
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache";
|
import { Button } from "@/components/ui/button"
|
||||||
import { SETTINGS_PAGE } from "@/css/app/styles";
|
import {
|
||||||
import { t } from "@/i18n/T";
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
|
||||||
export function EmailPage() {
|
export function EmailPage() {
|
||||||
const { email: cachedEmail, setEmail: setCachedEmail } = useSettingsDataCache();
|
const { email: cachedEmail, setEmail: setCachedEmail } =
|
||||||
const [email, setEmail] = useState<string | null>(cachedEmail);
|
useSettingsDataCache()
|
||||||
const [loading, setLoading] = useState(cachedEmail === null);
|
const [email, setEmail] = useState<string | null>(cachedEmail)
|
||||||
const [saving, setSaving] = useState(false);
|
const [loading, setLoading] = useState(cachedEmail === null)
|
||||||
const [form, setForm] = useState({ new_email: "", password: "" });
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState({ new_email: "", password: "" })
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedEmail !== null) return;
|
if (cachedEmail !== null) return
|
||||||
(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiEmailGet();
|
const res = await apiEmailGet()
|
||||||
const e = res.data.data?.email ?? null;
|
const nextEmail = res.data.data?.email ?? null
|
||||||
setEmail(e);
|
setEmail(nextEmail)
|
||||||
setCachedEmail(e);
|
setCachedEmail(nextEmail)
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.email_page.load_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.email_page.load_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})();
|
})()
|
||||||
}, [cachedEmail, setCachedEmail]);
|
}, [cachedEmail, setCachedEmail])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.new_email || !form.password) {
|
if (!form.new_email || !form.password) {
|
||||||
setMessage({ type: "error", text: t("settings.email_page.fill_all_fields") });
|
setMessage({
|
||||||
return;
|
type: "error",
|
||||||
|
text: t("settings.email_page.fill_all_fields"),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
await apiEmailChange({
|
await apiEmailChange({
|
||||||
new_email: form.new_email,
|
new_email: form.new_email,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
});
|
})
|
||||||
setMessage({
|
setMessage({
|
||||||
type: "success",
|
type: "success",
|
||||||
text: t("settings.email_page.verification_sent"),
|
text: t("settings.email_page.verification_sent"),
|
||||||
});
|
})
|
||||||
setForm({ new_email: "", password: "" });
|
setForm({ new_email: "", password: "" })
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.email_page.change_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.email_page.change_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={SETTINGS_PAGE.loadingState}>
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="w-6 h-6 animate-spin"
|
className="size-6 animate-spin"
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<h1
|
<div className="flex flex-col gap-2">
|
||||||
className={SETTINGS_PAGE.pageHeader}
|
<h1
|
||||||
style={{ color: "var(--text-primary)" }}
|
className="text-[20px] font-bold"
|
||||||
>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{t("settings.email_page.title")}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className={SETTINGS_PAGE.pageSubtitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.email_page.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
>
|
||||||
{t("settings.email_page.current_email")}
|
{t("settings.email_page.title")}
|
||||||
</Label>
|
</h1>
|
||||||
<div
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
className="text-[14px] px-3 py-2 rounded-md"
|
{t("settings.email_page.subtitle")}
|
||||||
style={{
|
</p>
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
color: email ? "var(--text-primary)" : "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{email || t("settings.email_page.no_email_set")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formSection}>
|
<Card>
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
<CardHeader className="gap-2">
|
||||||
<Label
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
className={SETTINGS_PAGE.formLabel}
|
<Mail className="size-4" />
|
||||||
style={{ color: "var(--text-muted)" }}
|
{t("settings.email_page.current_email")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("settings.email_page.subtitle")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[12px] tracking-[0.2em] text-muted-foreground uppercase">
|
||||||
|
Current
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="truncate text-[14px] font-medium"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{email || t("settings.email_page.no_email_set")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-2.5 py-1 text-[11px]"
|
||||||
>
|
>
|
||||||
|
{email ? "Verified" : "Unset"}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="text-[15px]">
|
||||||
{t("settings.email_page.new_email")}
|
{t("settings.email_page.new_email")}
|
||||||
</Label>
|
</CardTitle>
|
||||||
<Input
|
<CardDescription>
|
||||||
type="email"
|
|
||||||
value={form.new_email}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, new_email: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.email.new_email_placeholder")}
|
|
||||||
className={SETTINGS_PAGE.formInput}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.email_page.current_password")}
|
{t("settings.email_page.current_password")}
|
||||||
</Label>
|
</CardDescription>
|
||||||
<Input
|
</CardHeader>
|
||||||
type="password"
|
<CardContent className="flex flex-col gap-5">
|
||||||
value={form.password}
|
<FieldGroup>
|
||||||
onChange={(e) =>
|
<Field>
|
||||||
setForm((f) => ({ ...f, password: e.target.value }))
|
<FieldLabel htmlFor="new-email">
|
||||||
}
|
<FieldTitle>{t("settings.email_page.new_email")}</FieldTitle>
|
||||||
placeholder={t("settings.email_page.current_password_placeholder")}
|
<FieldDescription>
|
||||||
className={SETTINGS_PAGE.formInput}
|
{t("settings.email.new_email_placeholder")}
|
||||||
style={{
|
</FieldDescription>
|
||||||
backgroundColor: "var(--surface-elevated)",
|
</FieldLabel>
|
||||||
borderColor: "var(--border-default)",
|
<FieldContent>
|
||||||
color: "var(--text-primary)",
|
<Input
|
||||||
}}
|
id="new-email"
|
||||||
/>
|
type="email"
|
||||||
</div>
|
value={form.new_email}
|
||||||
</div>
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, new_email: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t("settings.email.new_email_placeholder")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{message && (
|
<Field>
|
||||||
<div
|
<FieldLabel htmlFor="current-email-password">
|
||||||
className={SETTINGS_PAGE.message}
|
<FieldTitle>
|
||||||
style={{
|
{t("settings.email_page.current_password")}
|
||||||
color:
|
</FieldTitle>
|
||||||
message.type === "success"
|
<FieldDescription>
|
||||||
? "var(--success)"
|
Confirm the change with your current password.
|
||||||
: "var(--destructive)",
|
</FieldDescription>
|
||||||
}}
|
</FieldLabel>
|
||||||
>
|
<FieldContent>
|
||||||
{message.text}
|
<Input
|
||||||
</div>
|
id="current-email-password"
|
||||||
)}
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, password: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"settings.email_page.current_password_placeholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.buttonRowSimple}>
|
{message && (
|
||||||
<Button
|
<Alert
|
||||||
onClick={handleSave}
|
variant={message.type === "error" ? "destructive" : undefined}
|
||||||
disabled={saving}
|
className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
|
||||||
size="sm"
|
>
|
||||||
style={{
|
{message.type === "success" ? (
|
||||||
backgroundColor: "var(--accent)",
|
<CheckCircle2 className="size-4" />
|
||||||
color: "var(--accent-fg)",
|
) : (
|
||||||
}}
|
<AlertCircle className="size-4" />
|
||||||
>
|
)}
|
||||||
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />}
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
{t("settings.email_page.save_button")}
|
</Alert>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
className="min-w-28"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
|
{t("settings.email_page.save_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,336 +1,353 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { getMyProfile, updateMyProfile, uploadAvatar } from "@/client/api";
|
import { getMyProfile, updateMyProfile, uploadAvatar } from "@/client/api"
|
||||||
import type { ProfileResponse, UpdateProfileParams } from "@/client/model";
|
import type { ProfileResponse, UpdateProfileParams } from "@/client/model"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { Input } from "@/components/ui/input";
|
import {
|
||||||
import { Label } from "@/components/ui/label";
|
Card,
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
CardContent,
|
||||||
import { Loader2, Upload, Trash2 } from "lucide-react";
|
CardDescription,
|
||||||
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache";
|
CardHeader,
|
||||||
import { SETTINGS_PAGE } from "@/css/app/styles";
|
CardTitle,
|
||||||
import { t } from "@/i18n/T";
|
} from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Loader2, Upload, Trash2 } from "lucide-react"
|
||||||
|
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
|
||||||
|
import { SETTINGS_PAGE } from "@/css/app/styles"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
|
||||||
interface UpdateProfileRequest extends UpdateProfileParams {
|
interface UpdateProfileRequest extends UpdateProfileParams {
|
||||||
display_name?: string | null;
|
display_name?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MyAccountPage() {
|
export function MyAccountPage() {
|
||||||
const { profile: cachedProfile, setProfile: setCachedProfile } = useSettingsDataCache();
|
const { profile: cachedProfile, setProfile: setCachedProfile } =
|
||||||
const [profile, setProfile] = useState<ProfileResponse | null>(cachedProfile);
|
useSettingsDataCache()
|
||||||
const [loading, setLoading] = useState(!cachedProfile);
|
const [profile, setProfile] = useState<ProfileResponse | null>(cachedProfile)
|
||||||
const [saving, setSaving] = useState(false);
|
const [loading, setLoading] = useState(!cachedProfile)
|
||||||
const [uploading, setUploading] = useState(false);
|
const [saving, setSaving] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
display_name: cachedProfile?.display_name ?? "",
|
display_name: cachedProfile?.display_name ?? "",
|
||||||
avatar_url: cachedProfile?.avatar_url ?? "",
|
avatar_url: cachedProfile?.avatar_url ?? "",
|
||||||
website_url: cachedProfile?.website_url ?? "",
|
website_url: cachedProfile?.website_url ?? "",
|
||||||
organization: cachedProfile?.organization ?? "",
|
organization: cachedProfile?.organization ?? "",
|
||||||
});
|
})
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedProfile) return;
|
if (cachedProfile) return
|
||||||
(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMyProfile();
|
const res = await getMyProfile()
|
||||||
const d = res.data.data!;
|
const d = res.data.data!
|
||||||
setProfile(d);
|
setProfile(d)
|
||||||
setCachedProfile(d);
|
setCachedProfile(d)
|
||||||
setForm({
|
setForm({
|
||||||
display_name: d.display_name ?? "",
|
display_name: d.display_name ?? "",
|
||||||
avatar_url: d.avatar_url ?? "",
|
avatar_url: d.avatar_url ?? "",
|
||||||
website_url: d.website_url ?? "",
|
website_url: d.website_url ?? "",
|
||||||
organization: d.organization ?? "",
|
organization: d.organization ?? "",
|
||||||
});
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.my_account.load_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.my_account.load_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})();
|
})()
|
||||||
}, [cachedProfile, setCachedProfile]);
|
}, [cachedProfile, setCachedProfile])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
await updateMyProfile({
|
await updateMyProfile({
|
||||||
display_name: form.display_name || null,
|
display_name: form.display_name || null,
|
||||||
avatar_url: form.avatar_url || null,
|
avatar_url: form.avatar_url || null,
|
||||||
website_url: form.website_url || null,
|
website_url: form.website_url || null,
|
||||||
organization: form.organization || null,
|
organization: form.organization || null,
|
||||||
} as UpdateProfileRequest);
|
} as UpdateProfileRequest)
|
||||||
setMessage({ type: "success", text: t("settings.my_account.save_success") });
|
const nextProfile = {
|
||||||
await loadProfile();
|
...(profile ?? cachedProfile ?? { username: "" }),
|
||||||
|
display_name: form.display_name || null,
|
||||||
|
avatar_url: form.avatar_url || null,
|
||||||
|
website_url: form.website_url || null,
|
||||||
|
organization: form.organization || null,
|
||||||
|
} as ProfileResponse
|
||||||
|
setProfile(nextProfile)
|
||||||
|
setCachedProfile(nextProfile)
|
||||||
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.my_account.save_success"),
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.my_account.save_failed") });
|
setMessage({ type: "error", text: t("settings.my_account.save_failed") })
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const loadProfile = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
if (cachedProfile) return;
|
|
||||||
const res = await getMyProfile();
|
|
||||||
const d = res.data.data!;
|
|
||||||
setProfile(d);
|
|
||||||
setCachedProfile(d);
|
|
||||||
setForm({
|
|
||||||
display_name: d.display_name ?? "",
|
|
||||||
avatar_url: d.avatar_url ?? "",
|
|
||||||
website_url: d.website_url ?? "",
|
|
||||||
organization: d.organization ?? "",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: "error", text: t("settings.my_account.load_failed") });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0]
|
||||||
if (!file) return;
|
if (!file) return
|
||||||
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
setMessage({ type: "error", text: t("settings.my_account.avatar_size_error") });
|
setMessage({
|
||||||
return;
|
type: "error",
|
||||||
|
text: t("settings.my_account.avatar_size_error"),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUploading(true);
|
setUploading(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
const formData = new FormData();
|
const formData = new FormData()
|
||||||
formData.append("file", file);
|
formData.append("file", file)
|
||||||
|
|
||||||
const res = await uploadAvatar(formData);
|
const res = await uploadAvatar(formData)
|
||||||
const newAvatarUrl = res.data.data?.avatar_url;
|
const newAvatarUrl = res.data.data?.avatar_url
|
||||||
if (newAvatarUrl) {
|
if (newAvatarUrl) {
|
||||||
setForm(f => ({ ...f, avatar_url: newAvatarUrl }));
|
setForm((f) => ({ ...f, avatar_url: newAvatarUrl }))
|
||||||
setMessage({ type: "success", text: t("settings.my_account.avatar_upload_success") });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.my_account.avatar_upload_success"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.my_account.avatar_upload_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.my_account.avatar_upload_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const removeAvatar = () => {
|
const removeAvatar = () => {
|
||||||
setForm(f => ({ ...f, avatar_url: "" }));
|
setForm((f) => ({ ...f, avatar_url: "" }))
|
||||||
};
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={SETTINGS_PAGE.loadingState}>
|
<div className={SETTINGS_PAGE.loadingState}>
|
||||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--text-muted)" }} />
|
<Loader2
|
||||||
|
className="size-6 animate-spin"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<h1
|
<div className="space-y-2">
|
||||||
className={SETTINGS_PAGE.pageHeader}
|
<h1
|
||||||
style={{ color: "var(--text-primary)" }}
|
className={SETTINGS_PAGE.pageHeader}
|
||||||
>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{t("settings.my_account.title")}
|
>
|
||||||
</h1>
|
{t("settings.my_account.title")}
|
||||||
<p
|
</h1>
|
||||||
className={SETTINGS_PAGE.pageSubtitle}
|
<p
|
||||||
style={{ color: "var(--text-muted)" }}
|
className={SETTINGS_PAGE.pageSubtitle}
|
||||||
>
|
|
||||||
{t("settings.my_account.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Avatar Section */}
|
|
||||||
<div className={SETTINGS_PAGE.avatarSection}>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
>
|
>
|
||||||
{t("settings.my_account.avatar")}
|
{t("settings.my_account.subtitle")}
|
||||||
</Label>
|
</p>
|
||||||
<div className={SETTINGS_PAGE.avatarRow}>
|
</div>
|
||||||
<Avatar className="w-20 h-20 rounded-lg border-2" style={{ borderColor: "var(--border-default)" }}>
|
|
||||||
<AvatarImage
|
|
||||||
src={form.avatar_url || undefined}
|
|
||||||
alt={profile?.username}
|
|
||||||
/>
|
|
||||||
<AvatarFallback
|
|
||||||
className="text-2xl rounded-lg"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{profile?.username?.[0]?.toUpperCase() || "U"}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<Card>
|
||||||
<div className="flex items-center gap-2">
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle>{t("settings.my_account.avatar")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("settings.my_account.avatar_hint")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
|
<Avatar className="size-20 rounded-2xl ring-1 ring-[var(--border-subtle)]">
|
||||||
|
<AvatarImage
|
||||||
|
src={form.avatar_url || undefined}
|
||||||
|
alt={profile?.username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback
|
||||||
|
className="rounded-2xl text-2xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-fg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile?.username?.[0]?.toUpperCase() || "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
disabled={uploading}
|
||||||
disabled={uploading}
|
|
||||||
>
|
>
|
||||||
{uploading ? <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-2" />}
|
{uploading ? (
|
||||||
{t("settings.my_account.upload_avatar")}
|
<Loader2
|
||||||
|
data-icon="inline-start"
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Upload data-icon="inline-start" />
|
||||||
|
)}
|
||||||
|
{t("settings.my_account.upload_avatar")}
|
||||||
</Button>
|
</Button>
|
||||||
{form.avatar_url && (
|
{form.avatar_url && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
onClick={removeAvatar}
|
onClick={removeAvatar}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
<Trash2 data-icon="inline-start" />
|
||||||
{t("settings.my_account.remove")}
|
{t("settings.my_account.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={SETTINGS_PAGE.avatarHint}>
|
<input
|
||||||
{t("settings.my_account.avatar_hint")}
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle>Profile details</CardTitle>
|
||||||
|
<CardDescription>{t("settings.my_account.subtitle")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-5">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label
|
||||||
|
className={SETTINGS_PAGE.formLabel}
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("settings.my_account.username")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={profile?.username ?? ""}
|
||||||
|
disabled
|
||||||
|
className={SETTINGS_PAGE.formInput}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--surface-elevated)",
|
||||||
|
borderColor: "var(--border-default)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Fields */}
|
<div className="grid gap-2">
|
||||||
<div className={SETTINGS_PAGE.formSection}>
|
<Label
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
className={SETTINGS_PAGE.formLabel}
|
||||||
<Label
|
style={{ color: "var(--text-muted)" }}
|
||||||
className={SETTINGS_PAGE.formLabel}
|
>
|
||||||
style={{ color: "var(--text-muted)" }}
|
{t("settings.my_account.display_name")}
|
||||||
>
|
</Label>
|
||||||
{t("settings.my_account.username")}
|
<Input
|
||||||
</Label>
|
value={form.display_name}
|
||||||
<Input
|
onChange={(e) =>
|
||||||
value={profile?.username ?? ""}
|
setForm((f) => ({ ...f, display_name: e.target.value }))
|
||||||
disabled
|
}
|
||||||
className={SETTINGS_PAGE.formInput}
|
placeholder={t("settings.my_account.display_name_placeholder")}
|
||||||
style={{
|
className={SETTINGS_PAGE.formInput}
|
||||||
backgroundColor: "var(--surface-elevated)",
|
style={{
|
||||||
borderColor: "var(--border-default)",
|
backgroundColor: "var(--surface-elevated)",
|
||||||
color: "var(--text-muted)",
|
borderColor: "var(--border-default)",
|
||||||
}}
|
color: "var(--text-primary)",
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
<div className="grid gap-2">
|
||||||
<Label
|
<Label
|
||||||
className={SETTINGS_PAGE.formLabel}
|
className={SETTINGS_PAGE.formLabel}
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
>
|
>
|
||||||
{t("settings.my_account.display_name")}
|
{t("settings.my_account.website")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.display_name}
|
value={form.website_url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, display_name: e.target.value }))
|
setForm((f) => ({ ...f, website_url: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder={t("settings.my_account.display_name_placeholder")}
|
placeholder={t("settings.my_account.website_placeholder")}
|
||||||
className={SETTINGS_PAGE.formInput}
|
className={SETTINGS_PAGE.formInput}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--surface-elevated)",
|
backgroundColor: "var(--surface-elevated)",
|
||||||
borderColor: "var(--border-default)",
|
borderColor: "var(--border-default)",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
<div className="grid gap-2">
|
||||||
<Label
|
<Label
|
||||||
className={SETTINGS_PAGE.formLabel}
|
className={SETTINGS_PAGE.formLabel}
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
>
|
>
|
||||||
{t("settings.my_account.website")}
|
{t("settings.my_account.organization")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.website_url}
|
value={form.organization}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, website_url: e.target.value }))
|
setForm((f) => ({ ...f, organization: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder={t("settings.my_account.website_placeholder")}
|
placeholder={t("settings.my_account.org_placeholder")}
|
||||||
className={SETTINGS_PAGE.formInput}
|
className={SETTINGS_PAGE.formInput}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--surface-elevated)",
|
backgroundColor: "var(--surface-elevated)",
|
||||||
borderColor: "var(--border-default)",
|
borderColor: "var(--border-default)",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.my_account.organization")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={form.organization}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, organization: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.my_account.org_placeholder")}
|
|
||||||
className={SETTINGS_PAGE.formInput}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<Alert
|
||||||
className={SETTINGS_PAGE.message}
|
variant={message.type === "error" ? "destructive" : "default"}
|
||||||
style={{
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
color:
|
|
||||||
message.type === "success"
|
|
||||||
? "var(--success)"
|
|
||||||
: "var(--destructive)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{message.text}
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save Button */}
|
<div className="flex justify-end">
|
||||||
<div className={SETTINGS_PAGE.buttonRow}>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{
|
className="min-w-28"
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />}
|
{saving && (
|
||||||
|
<Loader2 data-icon="inline-start" className="animate-spin" />
|
||||||
|
)}
|
||||||
{t("settings.my_account.save_changes")}
|
{t("settings.my_account.save_changes")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,89 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
|
import { Bell, Loader2, Settings2, Sparkles, ShieldAlert } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
getNotificationPreferences,
|
getNotificationPreferences,
|
||||||
updateNotificationPreferences,
|
updateNotificationPreferences,
|
||||||
} from "@/client/api";
|
} from "@/client/api"
|
||||||
import type { NotificationPreferencesResponse } from "@/client/model";
|
import type { NotificationPreferencesResponse } from "@/client/model"
|
||||||
import { Button } from "@/components/ui/button";
|
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select"
|
||||||
import { Loader2 } from "lucide-react";
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { useSettingsDataCache } from "@/components/settings/SettingsDataCache";
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { NOTIFICATIONS_PAGE } from "@/css/app/styles";
|
import { t } from "@/i18n/T"
|
||||||
import { t } from "@/i18n/T";
|
|
||||||
|
|
||||||
const DIGEST_MODES = [
|
const DIGEST_MODES = [
|
||||||
{ value: "instant", label: t("settings.notifications_page.instant") },
|
{ value: "instant", label: t("settings.notifications_page.instant") },
|
||||||
{ value: "daily", label: t("settings.notifications_page.daily_digest") },
|
{ value: "daily", label: t("settings.notifications_page.daily_digest") },
|
||||||
{ value: "weekly", label: t("settings.notifications_page.weekly_digest") },
|
{ value: "weekly", label: t("settings.notifications_page.weekly_digest") },
|
||||||
{ value: "off", label: t("settings.notifications_page.off") },
|
{ value: "off", label: t("settings.notifications_page.off") },
|
||||||
];
|
]
|
||||||
|
|
||||||
const ToggleRow = ({
|
function ToggleRow({
|
||||||
label,
|
label,
|
||||||
desc,
|
desc,
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string
|
||||||
desc: string;
|
desc: string
|
||||||
checked: boolean;
|
checked: boolean
|
||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void
|
||||||
}) => (
|
}) {
|
||||||
<div className={NOTIFICATIONS_PAGE.toggleRow}>
|
return (
|
||||||
<div className="flex-1 pr-4">
|
<div className="flex items-start justify-between gap-4 py-4">
|
||||||
<p className={NOTIFICATIONS_PAGE.toggleLabel} style={{ color: "var(--text-primary)" }}>
|
<div className="min-w-0">
|
||||||
{label}
|
<p
|
||||||
</p>
|
className="text-[14px] font-medium"
|
||||||
<p className={NOTIFICATIONS_PAGE.toggleLabelDesc} style={{ color: "var(--text-muted)" }}>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{desc}
|
>
|
||||||
</p>
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="mt-0.5 text-[12px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={checked} onCheckedChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={checked} onCheckedChange={onChange} />
|
)
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
|
|
||||||
export function NotificationsPage() {
|
export function NotificationsPage() {
|
||||||
const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache();
|
const {
|
||||||
const [, setPrefs] =
|
notificationPrefs: cachedPrefs,
|
||||||
useState<NotificationPreferencesResponse | null>(cachedPrefs);
|
setNotificationPrefs: setCachedPrefs,
|
||||||
const [loading, setLoading] = useState(!cachedPrefs);
|
} = useSettingsDataCache()
|
||||||
const [saving, setSaving] = useState(false);
|
const [, setPrefs] = useState<NotificationPreferencesResponse | null>(
|
||||||
|
cachedPrefs
|
||||||
|
)
|
||||||
|
const [loading, setLoading] = useState(!cachedPrefs)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
email_enabled: cachedPrefs?.email_enabled ?? true,
|
email_enabled: cachedPrefs?.email_enabled ?? true,
|
||||||
in_app_enabled: cachedPrefs?.in_app_enabled ?? true,
|
in_app_enabled: cachedPrefs?.in_app_enabled ?? true,
|
||||||
@ -65,42 +93,45 @@ export function NotificationsPage() {
|
|||||||
marketing_enabled: cachedPrefs?.marketing_enabled ?? false,
|
marketing_enabled: cachedPrefs?.marketing_enabled ?? false,
|
||||||
security_enabled: cachedPrefs?.security_enabled ?? true,
|
security_enabled: cachedPrefs?.security_enabled ?? true,
|
||||||
product_enabled: cachedPrefs?.product_enabled ?? true,
|
product_enabled: cachedPrefs?.product_enabled ?? true,
|
||||||
});
|
})
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedPrefs) return;
|
if (cachedPrefs) return
|
||||||
(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getNotificationPreferences();
|
const res = await getNotificationPreferences()
|
||||||
const d = res.data.data!;
|
const data = res.data.data!
|
||||||
setPrefs(d);
|
setPrefs(data)
|
||||||
setCachedPrefs(d);
|
setCachedPrefs(data)
|
||||||
setForm({
|
setForm({
|
||||||
email_enabled: d.email_enabled,
|
email_enabled: data.email_enabled,
|
||||||
in_app_enabled: d.in_app_enabled,
|
in_app_enabled: data.in_app_enabled,
|
||||||
push_enabled: d.push_enabled,
|
push_enabled: data.push_enabled,
|
||||||
digest_mode: d.digest_mode,
|
digest_mode: data.digest_mode,
|
||||||
dnd_enabled: d.dnd_enabled,
|
dnd_enabled: data.dnd_enabled,
|
||||||
marketing_enabled: d.marketing_enabled,
|
marketing_enabled: data.marketing_enabled,
|
||||||
security_enabled: d.security_enabled,
|
security_enabled: data.security_enabled,
|
||||||
product_enabled: d.product_enabled,
|
product_enabled: data.product_enabled,
|
||||||
});
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.notifications_page.load_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.notifications_page.load_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})();
|
})()
|
||||||
}, [cachedPrefs, setCachedPrefs]);
|
}, [cachedPrefs, setCachedPrefs])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
await updateNotificationPreferences({
|
await updateNotificationPreferences({
|
||||||
email_enabled: form.email_enabled,
|
email_enabled: form.email_enabled,
|
||||||
in_app_enabled: form.in_app_enabled,
|
in_app_enabled: form.in_app_enabled,
|
||||||
@ -110,228 +141,202 @@ export function NotificationsPage() {
|
|||||||
marketing_enabled: form.marketing_enabled,
|
marketing_enabled: form.marketing_enabled,
|
||||||
security_enabled: form.security_enabled,
|
security_enabled: form.security_enabled,
|
||||||
product_enabled: form.product_enabled,
|
product_enabled: form.product_enabled,
|
||||||
});
|
})
|
||||||
setMessage({ type: "success", text: t("settings.notifications_page.save_success") });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.notifications_page.save_success"),
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.notifications_page.save_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.notifications_page.save_failed"),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={NOTIFICATIONS_PAGE.loadingState}>
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="w-6 h-6 animate-spin"
|
className="size-6 animate-spin"
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<h1
|
<div className="flex flex-col gap-2">
|
||||||
className={NOTIFICATIONS_PAGE.pageHeader}
|
<h1
|
||||||
style={{ color: "var(--text-primary)" }}
|
className="text-[20px] font-bold"
|
||||||
>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{t("settings.notifications_page.title")}
|
>
|
||||||
</h1>
|
{t("settings.notifications_page.title")}
|
||||||
<p
|
</h1>
|
||||||
className={NOTIFICATIONS_PAGE.pageSubtitle}
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
style={{ color: "var(--text-muted)" }}
|
{t("settings.notifications_page.subtitle")}
|
||||||
>
|
</p>
|
||||||
{t("settings.notifications_page.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={NOTIFICATIONS_PAGE.sectionGroup}>
|
|
||||||
{/* Notification Channels */}
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
className={NOTIFICATIONS_PAGE.sectionTitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.notifications_page.channels")}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleContainer}
|
|
||||||
style={{ border: "1px solid var(--border-subtle)" }}
|
|
||||||
>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.email_notifications")}
|
|
||||||
desc={t("settings.notifications_page.email_notifications_desc")}
|
|
||||||
checked={form.email_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, email_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleDivider}
|
|
||||||
style={{ borderTop: "1px solid var(--border-subtle)" }}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.in_app_notifications")}
|
|
||||||
desc={t("settings.notifications_page.in_app_notifications_desc")}
|
|
||||||
checked={form.in_app_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, in_app_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleDivider}
|
|
||||||
style={{ borderTop: "1px solid var(--border-subtle)" }}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.push_notifications")}
|
|
||||||
desc={t("settings.notifications_page.push_notifications_desc")}
|
|
||||||
checked={form.push_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, push_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Digest Mode */}
|
|
||||||
<div>
|
|
||||||
<Label
|
|
||||||
className={NOTIFICATIONS_PAGE.sectionTitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.notifications_page.digest_mode")}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={form.digest_mode}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, digest_mode: v }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className={NOTIFICATIONS_PAGE.selectTrigger}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{DIGEST_MODES.map((m) => (
|
|
||||||
<SelectItem key={m.value} value={m.value}>
|
|
||||||
{m.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notification Types */}
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
className={NOTIFICATIONS_PAGE.sectionTitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.notifications_page.notification_types")}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleContainer}
|
|
||||||
style={{ border: "1px solid var(--border-subtle)" }}
|
|
||||||
>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.security_notifications")}
|
|
||||||
desc={t("settings.notifications_page.security_notifications_desc")}
|
|
||||||
checked={form.security_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, security_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleDivider}
|
|
||||||
style={{ borderTop: "1px solid var(--border-subtle)" }}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.product_updates")}
|
|
||||||
desc={t("settings.notifications_page.product_updates_desc")}
|
|
||||||
checked={form.product_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, product_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleDivider}
|
|
||||||
style={{ borderTop: "1px solid var(--border-subtle)" }}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.marketing_emails")}
|
|
||||||
desc={t("settings.notifications_page.marketing_emails_desc")}
|
|
||||||
checked={form.marketing_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, marketing_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DND */}
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
className={NOTIFICATIONS_PAGE.sectionTitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.notifications_page.do_not_disturb")}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
className={NOTIFICATIONS_PAGE.toggleContainer}
|
|
||||||
style={{ border: "1px solid var(--border-subtle)" }}
|
|
||||||
>
|
|
||||||
<ToggleRow
|
|
||||||
label={t("settings.notifications_page.enable_dnd")}
|
|
||||||
desc={t("settings.notifications_page.enable_dnd_desc")}
|
|
||||||
checked={form.dnd_enabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
setForm((f) => ({ ...f, dnd_enabled: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
|
<Bell className="size-4" />
|
||||||
|
{t("settings.notifications_page.channels")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Route notifications to the right place for your workflow.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col pb-2">
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.email_notifications")}
|
||||||
|
desc={t("settings.notifications_page.email_notifications_desc")}
|
||||||
|
checked={form.email_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, email_enabled: v }))}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.in_app_notifications")}
|
||||||
|
desc={t("settings.notifications_page.in_app_notifications_desc")}
|
||||||
|
checked={form.in_app_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, in_app_enabled: v }))}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.push_notifications")}
|
||||||
|
desc={t("settings.notifications_page.push_notifications_desc")}
|
||||||
|
checked={form.push_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, push_enabled: v }))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
{t("settings.notifications_page.digest_mode")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("settings.notifications_page.digest_mode")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="digest-mode">
|
||||||
|
<FieldTitle>
|
||||||
|
{t("settings.notifications_page.digest_mode")}
|
||||||
|
</FieldTitle>
|
||||||
|
<FieldDescription>
|
||||||
|
Pick how often digest notifications should arrive.
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Select
|
||||||
|
value={form.digest_mode}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, digest_mode: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="digest-mode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DIGEST_MODES.map((mode) => (
|
||||||
|
<SelectItem key={mode.value} value={mode.value}>
|
||||||
|
{mode.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
{t("settings.notifications_page.notification_types")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Filter the product and security events you care about.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col pb-2">
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.security_notifications")}
|
||||||
|
desc={t("settings.notifications_page.security_notifications_desc")}
|
||||||
|
checked={form.security_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, security_enabled: v }))}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.product_updates")}
|
||||||
|
desc={t("settings.notifications_page.product_updates_desc")}
|
||||||
|
checked={form.product_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, product_enabled: v }))}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.marketing_emails")}
|
||||||
|
desc={t("settings.notifications_page.marketing_emails_desc")}
|
||||||
|
checked={form.marketing_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, marketing_enabled: v }))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
|
<ShieldAlert className="size-4" />
|
||||||
|
{t("settings.notifications_page.do_not_disturb")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Silence notification delivery during focused time.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col pb-2">
|
||||||
|
<ToggleRow
|
||||||
|
label={t("settings.notifications_page.enable_dnd")}
|
||||||
|
desc={t("settings.notifications_page.enable_dnd_desc")}
|
||||||
|
checked={form.dnd_enabled}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, dnd_enabled: v }))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<Alert
|
||||||
className={NOTIFICATIONS_PAGE.message}
|
variant={message.type === "error" ? "destructive" : undefined}
|
||||||
style={{
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
color:
|
|
||||||
message.type === "success"
|
|
||||||
? "var(--success)"
|
|
||||||
: "var(--destructive)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{message.text}
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={NOTIFICATIONS_PAGE.buttonRow}>
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Badge variant="outline" className="rounded-full px-3 py-1 text-[11px]">
|
||||||
|
{form.push_enabled ? "Push on" : "Push off"}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{
|
className="min-w-28"
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />}
|
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
{t("settings.notifications_page.save_button")}
|
{t("settings.notifications_page.save_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,166 +1,197 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { apiUserChangePassword } from "@/client/api";
|
import { AlertCircle, CheckCircle2, Loader2, ShieldCheck } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button";
|
import { apiUserChangePassword } from "@/client/api"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Loader2 } from "lucide-react";
|
import {
|
||||||
import { SETTINGS_PAGE } from "@/css/app/styles";
|
Card,
|
||||||
import { t } from "@/i18n/T";
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
|
||||||
export function PasswordPage() {
|
export function PasswordPage() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
old_password: "",
|
old_password: "",
|
||||||
new_password: "",
|
new_password: "",
|
||||||
confirm_password: "",
|
confirm_password: "",
|
||||||
});
|
})
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (form.new_password !== form.confirm_password) {
|
if (form.new_password !== form.confirm_password) {
|
||||||
setMessage({ type: "error", text: t("settings.password.mismatch") });
|
setMessage({ type: "error", text: t("settings.password.mismatch") })
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if (form.new_password.length < 8) {
|
if (form.new_password.length < 8) {
|
||||||
setMessage({ type: "error", text: t("settings.password.min_length") });
|
setMessage({ type: "error", text: t("settings.password.min_length") })
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
await apiUserChangePassword({
|
await apiUserChangePassword({
|
||||||
old_password: form.old_password,
|
old_password: form.old_password,
|
||||||
new_password: form.new_password,
|
new_password: form.new_password,
|
||||||
});
|
})
|
||||||
setMessage({ type: "success", text: t("settings.password.change_success") });
|
setMessage({
|
||||||
setForm({ old_password: "", new_password: "", confirm_password: "" });
|
type: "success",
|
||||||
|
text: t("settings.password.change_success"),
|
||||||
|
})
|
||||||
|
setForm({ old_password: "", new_password: "", confirm_password: "" })
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.password.change_failed") });
|
setMessage({ type: "error", text: t("settings.password.change_failed") })
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<h1
|
<div className="flex flex-col gap-2">
|
||||||
className={SETTINGS_PAGE.pageHeader}
|
<h1
|
||||||
style={{ color: "var(--text-primary)" }}
|
className="text-[20px] font-bold"
|
||||||
>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{t("settings.password.title")}
|
>
|
||||||
</h1>
|
{t("settings.password.title")}
|
||||||
<p
|
</h1>
|
||||||
className={SETTINGS_PAGE.pageSubtitle}
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
style={{ color: "var(--text-muted)" }}
|
{t("settings.password.subtitle")}
|
||||||
>
|
</p>
|
||||||
{t("settings.password.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formSection}>
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.password.current_password")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={form.old_password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, old_password: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.password.current_password_placeholder")}
|
|
||||||
className={SETTINGS_PAGE.formInput}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.password.new_password")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={form.new_password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, new_password: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.password.new_password_placeholder")}
|
|
||||||
className={SETTINGS_PAGE.formInput}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.formGroup}>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.password.confirm_password")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={form.confirm_password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
confirm_password: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.password.confirm_password_placeholder")}
|
|
||||||
className={SETTINGS_PAGE.formInput}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
<Card>
|
||||||
<div
|
<CardHeader className="gap-2">
|
||||||
className={SETTINGS_PAGE.message}
|
<CardTitle className="text-[15px]">
|
||||||
style={{
|
{t("settings.password.title")}
|
||||||
color:
|
</CardTitle>
|
||||||
message.type === "success"
|
<CardDescription>{t("settings.password.subtitle")}</CardDescription>
|
||||||
? "var(--success)"
|
</CardHeader>
|
||||||
: "var(--destructive)",
|
<CardContent className="flex flex-col gap-5">
|
||||||
}}
|
<Alert className="border-[var(--border-subtle)] bg-[var(--surface-ground)]">
|
||||||
>
|
<ShieldCheck className="size-4" />
|
||||||
{message.text}
|
<AlertTitle>{t("settings.password.title")}</AlertTitle>
|
||||||
</div>
|
<AlertDescription>
|
||||||
)}
|
Use a unique password that is at least 8 characters long.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className={SETTINGS_PAGE.buttonRowSimple}>
|
<FieldGroup>
|
||||||
<Button
|
<Field>
|
||||||
onClick={handleSave}
|
<FieldLabel htmlFor="current-password">
|
||||||
disabled={saving}
|
<FieldTitle>
|
||||||
size="sm"
|
{t("settings.password.current_password")}
|
||||||
style={{
|
</FieldTitle>
|
||||||
backgroundColor: "var(--accent)",
|
<FieldDescription>
|
||||||
color: "var(--accent-fg)",
|
{t("settings.password.current_password_placeholder")}
|
||||||
}}
|
</FieldDescription>
|
||||||
>
|
</FieldLabel>
|
||||||
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />}
|
<FieldContent>
|
||||||
{t("settings.password.submit")}
|
<Input
|
||||||
</Button>
|
id="current-password"
|
||||||
</div>
|
type="password"
|
||||||
|
value={form.old_password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, old_password: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"settings.password.current_password_placeholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="new-password">
|
||||||
|
<FieldTitle>{t("settings.password.new_password")}</FieldTitle>
|
||||||
|
<FieldDescription>
|
||||||
|
{t("settings.password.min_length")}
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={form.new_password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, new_password: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t("settings.password.new_password_placeholder")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="confirm-password">
|
||||||
|
<FieldTitle>
|
||||||
|
{t("settings.password.confirm_password")}
|
||||||
|
</FieldTitle>
|
||||||
|
<FieldDescription>
|
||||||
|
Re-enter the new password to confirm it.
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={form.confirm_password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
confirm_password: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"settings.password.confirm_password_placeholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<Alert
|
||||||
|
variant={message.type === "error" ? "destructive" : undefined}
|
||||||
|
className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
|
||||||
|
>
|
||||||
|
{message.type === "success" ? (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
)}
|
||||||
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
className="min-w-28"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
|
{t("settings.password.submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,94 +1,146 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
import { getNotificationPreferences, updateNotificationPreferences } from "@/client/api";
|
import {
|
||||||
import type { NotificationPreferencesResponse } from "@/client/model";
|
getNotificationPreferences,
|
||||||
import { Switch } from "@/components/ui/switch";
|
updateNotificationPreferences,
|
||||||
import { Label } from "@/components/ui/label";
|
} from "@/client/api"
|
||||||
import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react";
|
import type { NotificationPreferencesResponse } from "@/client/model"
|
||||||
import { t } from "@/i18n/T";
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
export function PushSettingsPage() {
|
export function PushSettingsPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
const [pushEnabled, setPushEnabled] = useState(false);
|
const [pushEnabled, setPushEnabled] = useState(false)
|
||||||
const canPush = 'Notification' in window && 'serviceWorker' in navigator;
|
const canPush = "Notification" in window && "serviceWorker" in navigator
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
const res = await getNotificationPreferences();
|
const res = await getNotificationPreferences()
|
||||||
const data = res.data.data as NotificationPreferencesResponse | undefined;
|
const data = res.data.data as
|
||||||
setPushEnabled(data?.push_enabled ?? false);
|
| NotificationPreferencesResponse
|
||||||
|
| undefined
|
||||||
|
setPushEnabled(data?.push_enabled ?? false)
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("settings.push.load_failed"));
|
setError(t("settings.push.load_failed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})();
|
})()
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const handleTogglePush = async (checked: boolean) => {
|
const handleTogglePush = async (checked: boolean) => {
|
||||||
if (checked && 'Notification' in window) {
|
if (checked && "Notification" in window) {
|
||||||
const permission = await Notification.requestPermission();
|
const permission = await Notification.requestPermission()
|
||||||
if (permission !== 'granted') {
|
if (permission !== "granted") {
|
||||||
setError(t("settings.push.permission_denied"));
|
setError(t("settings.push.permission_denied"))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setError(null);
|
setError(null)
|
||||||
await updateNotificationPreferences({
|
await updateNotificationPreferences({
|
||||||
push_enabled: checked,
|
push_enabled: checked,
|
||||||
} as Partial<NotificationPreferencesResponse>);
|
} as Partial<NotificationPreferencesResponse>)
|
||||||
setPushEnabled(checked);
|
setPushEnabled(checked)
|
||||||
setSuccess(true);
|
setSuccess(true)
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
setTimeout(() => setSuccess(false), 3000)
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("settings.push.update_failed"));
|
setError(t("settings.push.update_failed"))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--text-muted)" }} />
|
<Loader2
|
||||||
|
className="size-6 animate-spin"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-[20px] font-bold mb-1" style={{ color: "var(--text-primary)" }}>{t("settings.push.title")}</h1>
|
<div className="space-y-2">
|
||||||
<p className="text-[13px] mb-6" style={{ color: "var(--text-muted)" }}>
|
<h1
|
||||||
{t("settings.push.subtitle")}
|
className="text-[20px] font-bold"
|
||||||
</p>
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{t("settings.push.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("settings.push.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!canPush && (
|
{!canPush && (
|
||||||
<div className="mb-6 p-4 rounded-lg flex items-start gap-3" style={{ backgroundColor: "var(--warning-alpha10)", border: "1px solid var(--warning)" }}>
|
<Alert
|
||||||
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" style={{ color: "var(--warning)" }} />
|
variant="destructive"
|
||||||
<div className="text-sm" style={{ color: "var(--warning)" }}>
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
<p className="font-semibold mb-1">{t("settings.push.not_supported")}</p>
|
>
|
||||||
<p>{t("settings.push.not_supported_desc")}</p>
|
<AlertCircle className="size-4" />
|
||||||
</div>
|
<AlertDescription>
|
||||||
</div>
|
{t("settings.push.not_supported_desc")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||||||
|
<Smartphone className="size-4" />
|
||||||
|
{t("settings.push.enable")}
|
||||||
|
<Badge
|
||||||
|
variant={pushEnabled ? "secondary" : "outline"}
|
||||||
|
className="rounded-full px-2 py-0.5 text-[11px]"
|
||||||
|
>
|
||||||
|
{pushEnabled ? "On" : "Off"}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("settings.push.enable_desc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: "var(--accent-bg)" }}>
|
<div className="flex size-10 items-center justify-center rounded-full bg-[var(--accent-bg)]">
|
||||||
<Smartphone className="w-5 h-5" style={{ color: "var(--accent)" }} />
|
<Smartphone
|
||||||
|
className="size-5"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-0.5">
|
||||||
<Label className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>{t("settings.push.enable")}</Label>
|
<Label
|
||||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{t("settings.push.enable_desc")}</p>
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{t("settings.push.enable")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{pushEnabled
|
||||||
|
? t("settings.push.saved")
|
||||||
|
: t("settings.push.subtitle")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@ -96,43 +148,58 @@ export function PushSettingsPage() {
|
|||||||
onCheckedChange={handleTogglePush}
|
onCheckedChange={handleTogglePush}
|
||||||
disabled={saving || !canPush}
|
disabled={saving || !canPush}
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{pushEnabled && (
|
{pushEnabled && (
|
||||||
<div className="p-4 rounded-lg border space-y-4" style={{ borderColor: "var(--border-default)" }}>
|
<Card>
|
||||||
<h3 className="text-xs font-semibold uppercase" style={{ color: "var(--text-muted)" }}>{t("settings.push.filters_title")}</h3>
|
<CardHeader className="gap-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<CardTitle className="text-[15px]">
|
||||||
<span style={{ color: "var(--text-primary)" }}>{t("settings.push.mentions")}</span>
|
{t("settings.push.filters_title")}
|
||||||
<Switch checked={true} disabled />
|
</CardTitle>
|
||||||
</div>
|
<CardDescription>{t("settings.push.coming_soon")}</CardDescription>
|
||||||
<div className="flex items-center justify-between text-sm">
|
</CardHeader>
|
||||||
<span style={{ color: "var(--text-primary)" }}>{t("settings.push.new_issues")}</span>
|
<CardContent className="grid gap-4">
|
||||||
<Switch checked={true} disabled />
|
<div className="flex items-center justify-between text-sm">
|
||||||
</div>
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
<div className="flex items-center justify-between text-sm">
|
{t("settings.push.mentions")}
|
||||||
<span style={{ color: "var(--text-primary)" }}>{t("settings.push.system_updates")}</span>
|
</span>
|
||||||
<Switch checked={false} disabled />
|
<Switch checked={true} disabled />
|
||||||
</div>
|
|
||||||
<p className="text-[10px] italic" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{t("settings.push.coming_soon")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Separator />
|
||||||
</div>
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
{t("settings.push.new_issues")}
|
||||||
|
</span>
|
||||||
|
<Switch checked={true} disabled />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
{t("settings.push.system_updates")}
|
||||||
|
</span>
|
||||||
|
<Switch checked={false} disabled />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 p-3 rounded text-xs flex items-center gap-2" style={{ backgroundColor: "var(--destructive-alpha10)", border: "1px solid var(--destructive)", color: "var(--destructive)" }}>
|
<Alert
|
||||||
<AlertCircle className="w-4 h-4" />
|
variant="destructive"
|
||||||
{error}
|
className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]"
|
||||||
</div>
|
>
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-4 p-3 rounded text-xs flex items-center gap-2" style={{ backgroundColor: "var(--success-alpha10)", border: "1px solid var(--success)", color: "var(--success)" }}>
|
<Alert className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
|
||||||
<ShieldCheck className="w-4 h-4" />
|
<ShieldCheck className="size-4" />
|
||||||
{t("settings.push.saved")}
|
<AlertDescription>{t("settings.push.saved")}</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react"
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Palette,
|
Palette,
|
||||||
@ -11,82 +11,111 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Key as KeyIcon,
|
Key as KeyIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
} from "lucide-react";
|
} from "lucide-react"
|
||||||
import { t } from "@/i18n/T";
|
import { t } from "@/i18n/T"
|
||||||
import { SETTINGS_LAYOUT } from "@/css/settings/styles";
|
|
||||||
|
|
||||||
const NAV_SECTIONS = [
|
const NAV_SECTIONS = [
|
||||||
{
|
{
|
||||||
label: t("settings.settings_nav.user_settings"),
|
label: t("settings.settings_nav.user_settings"),
|
||||||
items: [
|
items: [
|
||||||
{ to: "/me/settings", end: true, icon: User, label: t("settings.settings_nav.my_account") },
|
{
|
||||||
{ to: "/me/settings/billing", icon: CreditCard, label: t("settings.settings_nav.billing") },
|
to: "/me/settings",
|
||||||
{ to: "/me/settings/appearance", icon: Palette, label: t("settings.settings_nav.appearance") },
|
end: true,
|
||||||
{ to: "/me/settings/notifications", icon: Bell, label: t("settings.settings_nav.notifications") },
|
icon: User,
|
||||||
{ to: "/me/settings/push", icon: Smartphone, label: t("settings.settings_nav.push_settings") },
|
label: t("settings.settings_nav.my_account"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
label: t("settings.settings_nav.billing"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/appearance",
|
||||||
|
icon: Palette,
|
||||||
|
label: t("settings.settings_nav.appearance"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/notifications",
|
||||||
|
icon: Bell,
|
||||||
|
label: t("settings.settings_nav.notifications"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/push",
|
||||||
|
icon: Smartphone,
|
||||||
|
label: t("settings.settings_nav.push_settings"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("settings.settings_nav.security"),
|
label: t("settings.settings_nav.security"),
|
||||||
items: [
|
items: [
|
||||||
{ to: "/me/settings/password", icon: Shield, label: t("settings.settings_nav.change_password") },
|
{
|
||||||
{ to: "/me/settings/email", icon: Mail, label: t("settings.settings_nav.email") },
|
to: "/me/settings/password",
|
||||||
{ to: "/me/settings/ssh-keys", icon: Key, label: t("settings.settings_nav.ssh_keys") },
|
icon: Shield,
|
||||||
{ to: "/me/settings/access-keys", icon: KeyIcon, label: t("settings.settings_nav.access_tokens") },
|
label: t("settings.settings_nav.change_password"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/email",
|
||||||
|
icon: Mail,
|
||||||
|
label: t("settings.settings_nav.email"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/ssh-keys",
|
||||||
|
icon: Key,
|
||||||
|
label: t("settings.settings_nav.ssh_keys"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/me/settings/access-keys",
|
||||||
|
icon: KeyIcon,
|
||||||
|
label: t("settings.settings_nav.access_tokens"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const SETTINGS_RETURN_PATH_KEY = "settings_return_path";
|
const SETTINGS_RETURN_PATH_KEY = "settings_return_path"
|
||||||
|
|
||||||
export function SettingsLayout() {
|
export function SettingsLayout() {
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const location = useLocation();
|
const location = useLocation()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const currentSection = NAV_SECTIONS.flatMap((section) => section.items).find(
|
||||||
|
(item) => {
|
||||||
|
if (item.end) {
|
||||||
|
return location.pathname === item.to
|
||||||
|
}
|
||||||
|
return location.pathname.startsWith(item.to)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
contentRef.current.scrollTop = 0;
|
contentRef.current.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, [location.pathname])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
const returnPath = localStorage.getItem(SETTINGS_RETURN_PATH_KEY);
|
const returnPath = localStorage.getItem(SETTINGS_RETURN_PATH_KEY)
|
||||||
localStorage.removeItem(SETTINGS_RETURN_PATH_KEY);
|
localStorage.removeItem(SETTINGS_RETURN_PATH_KEY)
|
||||||
navigate(returnPath || "/me");
|
navigate(returnPath || "/me")
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-screen overflow-hidden bg-[var(--surface-ground)]">
|
||||||
className={SETTINGS_LAYOUT.container}
|
<nav className="flex w-[250px] shrink-0 flex-col overflow-y-auto border-r border-[var(--border-subtle)] bg-[var(--surface-rail)]/90 backdrop-blur-xl">
|
||||||
style={{
|
<div className="flex flex-col gap-1 px-4 pt-6 pb-4">
|
||||||
backgroundColor: "var(--surface-ground)",
|
<h2 className="text-[11px] font-semibold tracking-[0.24em] text-[var(--text-tertiary)] uppercase">
|
||||||
}}
|
{t("settings.settings_nav.user_settings")}
|
||||||
>
|
</h2>
|
||||||
<nav
|
<p className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
className={SETTINGS_LAYOUT.sidebar.container}
|
Settings
|
||||||
style={{
|
</p>
|
||||||
backgroundColor: "var(--surface-rail)",
|
|
||||||
borderRight: "1px solid var(--border-subtle)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={SETTINGS_LAYOUT.sidebar.header}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2
|
|
||||||
className={SETTINGS_LAYOUT.sidebar.headerTitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.settings_nav.user_settings")}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{NAV_SECTIONS.map((section, si) => (
|
{NAV_SECTIONS.map((section, si) => (
|
||||||
<div key={si} className={SETTINGS_LAYOUT.sidebar.section}>
|
<div key={si} className="mb-1">
|
||||||
<div
|
<div className="px-4 pt-4 pb-2 text-[11px] font-semibold tracking-[0.22em] text-[var(--text-tertiary)] uppercase">
|
||||||
className={SETTINGS_LAYOUT.sidebar.sectionLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{section.label}
|
{section.label}
|
||||||
</div>
|
</div>
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
@ -95,19 +124,16 @@ export function SettingsLayout() {
|
|||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`${SETTINGS_LAYOUT.navItem.default} ${isActive ? SETTINGS_LAYOUT.navItem.active : ""}`
|
[
|
||||||
}
|
"mx-3 flex items-center gap-3 rounded-xl px-3 py-2 text-[14px] transition-all",
|
||||||
style={({ isActive }) =>
|
isActive
|
||||||
isActive
|
? "bg-[var(--hover-bg-strong)] text-[var(--text-primary)] shadow-sm ring-1 ring-[var(--border-default)]"
|
||||||
? {
|
: "text-[var(--text-secondary)] hover:bg-[var(--hover-bg)] hover:text-[var(--text-primary)]",
|
||||||
color: "var(--text-primary)",
|
].join(" ")
|
||||||
backgroundColor: "var(--hover-bg-strong)",
|
|
||||||
}
|
|
||||||
: { color: "var(--text-secondary)" }
|
|
||||||
}
|
}
|
||||||
viewTransition
|
viewTransition
|
||||||
>
|
>
|
||||||
<item.icon className="w-4 h-4 shrink-0" />
|
<item.icon className="size-4 shrink-0" />
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
@ -115,33 +141,34 @@ export function SettingsLayout() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-full">
|
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||||
<div
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--border-subtle)] bg-[var(--surface-ground)]/80 px-4 py-3 backdrop-blur-xl">
|
||||||
className={SETTINGS_LAYOUT.topBar.container}
|
<div className="min-w-0">
|
||||||
style={{
|
<p className="text-[12px] font-semibold tracking-[0.22em] text-[var(--text-tertiary)] uppercase">
|
||||||
borderBottom: "1px solid var(--border-subtle)",
|
{t("settings.settings_nav.user_settings")}
|
||||||
backgroundColor: "var(--surface-ground)",
|
</p>
|
||||||
}}
|
<p className="truncate text-[14px] font-medium text-[var(--text-primary)]">
|
||||||
>
|
{currentSection?.label || "Settings"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className={SETTINGS_LAYOUT.topBar.closeButton}
|
className="inline-flex size-8 items-center justify-center rounded-full border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-colors hover:bg-[var(--hover-bg)] hover:text-[var(--text-primary)]"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
title={t("common.actions.close")}
|
||||||
>
|
>
|
||||||
<XIcon className="w-4 h-4" />
|
<XIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={SETTINGS_LAYOUT.content.container}
|
className="flex-1 overflow-y-auto bg-[var(--surface-ground)]"
|
||||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
|
||||||
>
|
>
|
||||||
<div className={SETTINGS_LAYOUT.content.wrapper}>
|
<div className="mx-auto max-w-[760px] px-6 py-8 lg:px-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,325 +1,373 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { Input } from "@/components/ui/input";
|
AlertCircle,
|
||||||
import { Label } from "@/components/ui/label";
|
Check,
|
||||||
import { Plus, Trash2, Key, Check } from "lucide-react";
|
Edit2,
|
||||||
import { useSshKeysQuery, useAddSshKeyMutation, useDeleteSshKeyMutation, useUpdateSshKeyMutation } from "@/hooks/useSshKeysQuery";
|
Key,
|
||||||
import { SSH_KEYS_PAGE, SETTINGS_PAGE } from "@/css/app/styles";
|
Loader2,
|
||||||
import { t } from "@/i18n/T";
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
useAddSshKeyMutation,
|
||||||
|
useDeleteSshKeyMutation,
|
||||||
|
useSshKeysQuery,
|
||||||
|
useUpdateSshKeyMutation,
|
||||||
|
} from "@/hooks/useSshKeysQuery"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { t } from "@/i18n/T"
|
||||||
|
|
||||||
export function SshKeysPage() {
|
export function SshKeysPage() {
|
||||||
const { data: keys = [], isLoading } = useSshKeysQuery();
|
const { data: keys = [], isLoading } = useSshKeysQuery()
|
||||||
const addMutation = useAddSshKeyMutation();
|
const addMutation = useAddSshKeyMutation()
|
||||||
const deleteMutation = useDeleteSshKeyMutation();
|
const deleteMutation = useDeleteSshKeyMutation()
|
||||||
const updateMutation = useUpdateSshKeyMutation();
|
const updateMutation = useUpdateSshKeyMutation()
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error"
|
||||||
text: string;
|
text: string
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
// Add form
|
const [addForm, setAddForm] = useState({ title: "", public_key: "" })
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [editingKey, setEditingKey] = useState<number | null>(null)
|
||||||
const [addForm, setAddForm] = useState({ title: "", public_key: "" });
|
const [editTitle, setEditTitle] = useState("")
|
||||||
|
|
||||||
// Edit form
|
|
||||||
const [editingKey, setEditingKey] = useState<number | null>(null);
|
|
||||||
const [editTitle, setEditTitle] = useState("");
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!addForm.title || !addForm.public_key) {
|
if (!addForm.title || !addForm.public_key) {
|
||||||
setMessage({ type: "error", text: t("settings.ssh_keys.messages.title_key_required") });
|
setMessage({
|
||||||
return;
|
type: "error",
|
||||||
|
text: t("settings.ssh_keys.messages.title_key_required"),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addMutation.mutateAsync({
|
await addMutation.mutateAsync({
|
||||||
title: addForm.title,
|
title: addForm.title,
|
||||||
public_key: addForm.public_key,
|
public_key: addForm.public_key,
|
||||||
});
|
})
|
||||||
setMessage({ type: "success", text: t("settings.ssh_keys.messages.add_success") });
|
setMessage({
|
||||||
setShowAdd(false);
|
type: "success",
|
||||||
setAddForm({ title: "", public_key: "" });
|
text: t("settings.ssh_keys.messages.add_success"),
|
||||||
|
})
|
||||||
|
setShowAdd(false)
|
||||||
|
setAddForm({ title: "", public_key: "" })
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.ssh_keys.messages.add_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.ssh_keys.messages.add_failed"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = async (keyId: number) => {
|
const handleDelete = async (keyId: number) => {
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(keyId);
|
await deleteMutation.mutateAsync(keyId)
|
||||||
setMessage({ type: "success", text: t("settings.ssh_keys.messages.delete_success") });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.ssh_keys.messages.delete_success"),
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.ssh_keys.messages.delete_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.ssh_keys.messages.delete_failed"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSaveTitle = async (keyId: number) => {
|
const handleSaveTitle = async (keyId: number) => {
|
||||||
try {
|
try {
|
||||||
await updateMutation.mutateAsync({ keyId, title: editTitle });
|
await updateMutation.mutateAsync({ keyId, title: editTitle })
|
||||||
setEditingKey(null);
|
setEditingKey(null)
|
||||||
setMessage({ type: "success", text: t("settings.ssh_keys.messages.title_updated") });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: t("settings.ssh_keys.messages.title_updated"),
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: t("settings.ssh_keys.messages.update_failed") });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: t("settings.ssh_keys.messages.update_failed"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatFingerprint = (fp: string) => {
|
const formatFingerprint = (fp: string) => {
|
||||||
if (fp.length <= 56) return fp;
|
if (fp.length <= 56) return fp
|
||||||
return fp.slice(0, 28) + "..." + fp.slice(-28);
|
return `${fp.slice(0, 28)}...${fp.slice(-28)}`
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={SSH_KEYS_PAGE.loadingState}>
|
<div className="flex items-center justify-center py-20">
|
||||||
<div
|
<Loader2
|
||||||
className="w-6 h-6 rounded-full border-2 border-muted border-t-accent animate-spin"
|
className="size-6 animate-spin"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
<div className={SSH_KEYS_PAGE.headerRow}>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
className={SSH_KEYS_PAGE.pageHeader}
|
<h1
|
||||||
style={{ color: "var(--text-primary)" }}
|
className="text-[20px] font-bold"
|
||||||
>
|
style={{ color: "var(--text-primary)" }}
|
||||||
{t("settings.ssh_keys.title")}
|
>
|
||||||
</h1>
|
{t("settings.ssh_keys.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("settings.ssh_keys.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAdd(true);
|
setShowAdd(true)
|
||||||
setMessage(null);
|
setMessage(null)
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
<Plus className="mr-2 size-4" />
|
||||||
{t("settings.ssh_keys.add_button")}
|
{t("settings.ssh_keys.add_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
className={SSH_KEYS_PAGE.pageSubtitle}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.ssh_keys.description")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Add Form */}
|
{message && (
|
||||||
{showAdd && (
|
<Alert
|
||||||
<div
|
variant={message.type === "error" ? "destructive" : undefined}
|
||||||
className={SSH_KEYS_PAGE.addForm}
|
className="border-[var(--border-subtle)] bg-[var(--surface-ground)]"
|
||||||
style={{
|
|
||||||
border: "1px solid var(--accent)",
|
|
||||||
backgroundColor: "var(--surface-elevated)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<h3
|
<AlertCircle className="size-4" />
|
||||||
className={SSH_KEYS_PAGE.addFormTitle}
|
<AlertDescription>{message.text}</AlertDescription>
|
||||||
style={{ color: "var(--text-primary)" }}
|
</Alert>
|
||||||
>
|
|
||||||
{t("settings.ssh_keys.add_title")}
|
|
||||||
</h3>
|
|
||||||
<div className={SSH_KEYS_PAGE.addFormFields}>
|
|
||||||
<div>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.ssh_keys.title_label")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={addForm.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAddForm((f) => ({ ...f, title: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.ssh_keys.name_placeholder")}
|
|
||||||
className={SETTINGS_PAGE.formInput}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-ground)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label
|
|
||||||
className={SETTINGS_PAGE.formLabel}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("settings.ssh_keys.public_key_label")}
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
value={addForm.public_key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAddForm((f) => ({ ...f, public_key: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={t("settings.ssh_keys.key_placeholder")}
|
|
||||||
rows={4}
|
|
||||||
className="w-full text-[13px] px-3 py-2 rounded-md font-mono resize-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--surface-ground)",
|
|
||||||
borderColor: "var(--border-default)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={SSH_KEYS_PAGE.addFormButtons}>
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={addMutation.isPending}
|
|
||||||
size="sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-fg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{addMutation.isPending && (
|
|
||||||
<div className="w-3.5 h-3.5 mr-1.5 rounded-full border-2 border-accent-fg/30 border-t-accent-fg animate-spin" />
|
|
||||||
)}
|
|
||||||
{t("common.actions.add")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAdd(false);
|
|
||||||
setAddForm({ title: "", public_key: "" });
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("common.actions.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Keys List */}
|
{showAdd && (
|
||||||
{keys.length === 0 ? (
|
<Card>
|
||||||
<div
|
<CardHeader className="gap-2">
|
||||||
className={SSH_KEYS_PAGE.emptyState}
|
<CardTitle className="text-[15px]">
|
||||||
style={{
|
{t("settings.ssh_keys.add_title")}
|
||||||
backgroundColor: "var(--surface-elevated)",
|
</CardTitle>
|
||||||
color: "var(--text-muted)",
|
<CardDescription>
|
||||||
}}
|
Register a new SSH public key for this account.
|
||||||
>
|
</CardDescription>
|
||||||
<Key className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
</CardHeader>
|
||||||
<p className="text-[14px]">{t("settings.ssh_keys.empty_title")}</p>
|
<CardContent className="flex flex-col gap-5">
|
||||||
<p className="text-[12px] mt-1">
|
<FieldGroup>
|
||||||
{t("settings.ssh_keys.empty_desc")}
|
<Field>
|
||||||
</p>
|
<FieldLabel htmlFor="ssh-key-title">
|
||||||
</div>
|
<FieldTitle>{t("settings.ssh_keys.title_label")}</FieldTitle>
|
||||||
) : (
|
<FieldDescription>
|
||||||
<div className={SSH_KEYS_PAGE.keysList}>
|
{t("settings.ssh_keys.name_placeholder")}
|
||||||
{keys.map((key) => (
|
</FieldDescription>
|
||||||
<div
|
</FieldLabel>
|
||||||
key={key.id}
|
<FieldContent>
|
||||||
className={SSH_KEYS_PAGE.keyItem}
|
<Input
|
||||||
style={{
|
id="ssh-key-title"
|
||||||
backgroundColor: "var(--surface-elevated)",
|
value={addForm.title}
|
||||||
border: "1px solid var(--border-subtle)",
|
onChange={(e) =>
|
||||||
}}
|
setAddForm((f) => ({ ...f, title: e.target.value }))
|
||||||
>
|
}
|
||||||
<div className={SSH_KEYS_PAGE.keyItemHeader}>
|
placeholder={t("settings.ssh_keys.name_placeholder")}
|
||||||
<div className="flex-1 min-w-0">
|
/>
|
||||||
{editingKey === key.id ? (
|
</FieldContent>
|
||||||
<div className={SSH_KEYS_PAGE.keyTitleEdit}>
|
</Field>
|
||||||
<Input
|
|
||||||
value={editTitle}
|
<Field>
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
<FieldLabel htmlFor="ssh-key-public">
|
||||||
className={`${SETTINGS_PAGE.formInput} h-7`}
|
<FieldTitle>
|
||||||
style={{
|
{t("settings.ssh_keys.public_key_label")}
|
||||||
backgroundColor: "var(--surface-ground)",
|
</FieldTitle>
|
||||||
borderColor: "var(--border-default)",
|
<FieldDescription>
|
||||||
color: "var(--text-primary)",
|
Paste the full public key in one line or multiple lines.
|
||||||
}}
|
</FieldDescription>
|
||||||
/>
|
</FieldLabel>
|
||||||
<button
|
<FieldContent>
|
||||||
onClick={() => handleSaveTitle(key.id)}
|
<Textarea
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-[4px]"
|
id="ssh-key-public"
|
||||||
style={{ color: "var(--success)" }}
|
value={addForm.public_key}
|
||||||
>
|
onChange={(e) =>
|
||||||
<Check className="w-4 h-4" />
|
setAddForm((f) => ({ ...f, public_key: e.target.value }))
|
||||||
</button>
|
}
|
||||||
</div>
|
placeholder={t("settings.ssh_keys.key_placeholder")}
|
||||||
) : (
|
rows={4}
|
||||||
<p
|
className="min-h-28 font-mono text-[13px]"
|
||||||
className={SSH_KEYS_PAGE.keyTitle}
|
/>
|
||||||
style={{ color: "var(--text-primary)" }}
|
</FieldContent>
|
||||||
onClick={() => {
|
</Field>
|
||||||
setEditingKey(key.id);
|
</FieldGroup>
|
||||||
setEditTitle(key.title);
|
|
||||||
}}
|
<div className="flex items-center justify-end gap-2">
|
||||||
>
|
<Button
|
||||||
{key.title}
|
variant="ghost"
|
||||||
</p>
|
size="sm"
|
||||||
)}
|
onClick={() => {
|
||||||
<p
|
setShowAdd(false)
|
||||||
className={SSH_KEYS_PAGE.keyFingerprint}
|
setAddForm({ title: "", public_key: "" })
|
||||||
style={{ color: "var(--text-muted)" }}
|
}}
|
||||||
>
|
>
|
||||||
{formatFingerprint(key.fingerprint)}
|
<X className="mr-2 size-4" />
|
||||||
</p>
|
{t("common.actions.cancel")}
|
||||||
<div className={SSH_KEYS_PAGE.keyMeta}>
|
</Button>
|
||||||
<span
|
<Button
|
||||||
className={SSH_KEYS_PAGE.keyBadge}
|
onClick={handleAdd}
|
||||||
style={{
|
disabled={addMutation.isPending}
|
||||||
backgroundColor: "var(--hover-bg-strong)",
|
size="sm"
|
||||||
color: "var(--text-muted)",
|
>
|
||||||
}}
|
{addMutation.isPending && (
|
||||||
>
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
{key.key_type}
|
)}
|
||||||
</span>
|
{t("common.actions.add")}
|
||||||
{key.key_bits && (
|
</Button>
|
||||||
<span
|
|
||||||
className="text-[11px]"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{key.key_bits} bits
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{key.is_verified && (
|
|
||||||
<span
|
|
||||||
className="text-[11px] flex items-center gap-0.5"
|
|
||||||
style={{ color: "var(--success)" }}
|
|
||||||
>
|
|
||||||
<Check className="w-3 h-3" />
|
|
||||||
{t("settings.ssh_keys.verified")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(key.id)}
|
|
||||||
className={SSH_KEYS_PAGE.deleteButton}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
title={t("common.actions.delete")}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<Empty className="border border-dashed border-[var(--border-subtle)] bg-[var(--surface-secondary)]/70 py-12">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<Key />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>{t("settings.ssh_keys.empty_title")}</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
{t("settings.ssh_keys.empty_desc")}
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<Card key={key.id} size="sm">
|
||||||
|
<CardContent className="flex flex-col gap-4 pt-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{editingKey === key.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
placeholder={t("settings.ssh_keys.name_placeholder")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSaveTitle(key.id)}
|
||||||
|
>
|
||||||
|
<Save className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(null)
|
||||||
|
setEditTitle("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-start gap-2 text-left"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(key.id)
|
||||||
|
setEditTitle(key.title)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="truncate text-[14px] font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{key.title}
|
||||||
|
</p>
|
||||||
|
<Edit2
|
||||||
|
className="size-3.5"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className="mt-1 font-mono text-[12px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{formatFingerprint(key.fingerprint)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(key.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[12px]">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-full px-2.5 py-1"
|
||||||
|
>
|
||||||
|
{key.key_type}
|
||||||
|
</Badge>
|
||||||
|
{key.key_bits && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-2.5 py-1"
|
||||||
|
>
|
||||||
|
{key.key_bits} bits
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{key.is_verified && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full px-2.5 py-1 text-[var(--success)]"
|
||||||
|
>
|
||||||
|
<Check className="size-3" />
|
||||||
|
{t("settings.ssh_keys.verified")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
className={SSH_KEYS_PAGE.message}
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
message.type === "success"
|
|
||||||
? "var(--success)"
|
|
||||||
: "var(--destructive)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user