Adds a "Profile" card backed by /api/v1/profile (PATCH) for the four public-profile fields arcadia already had columns for. Bio moved out of local prefs (the server one supersedes); local prefs keeps only title, signature, defaultAgentId, and the avatar URL mirror. Save/revert mirror the existing Account card's pattern. The new fields get arcadia validation + audit logging for free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
704 lines
24 KiB
TypeScript
704 lines
24 KiB
TypeScript
import { useCallback, useEffect, useState } from "react"
|
|
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
|
|
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
|
import { AlertBanner } from "@crema/feedback-ui"
|
|
|
|
import { AppShell } from "~/components/layout/app-shell"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
|
import { Badge } from "~/components/ui/badge"
|
|
import { Button } from "~/components/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "~/components/ui/dropdown-menu"
|
|
import { Input } from "~/components/ui/input"
|
|
import { Textarea } from "~/components/ui/textarea"
|
|
import { useAgents } from "~/lib/agents"
|
|
import { pageTitle } from "~/lib/page-meta"
|
|
import {
|
|
profileInitials,
|
|
resetProfile,
|
|
saveProfile,
|
|
useProfile,
|
|
type Profile,
|
|
} from "~/lib/profile"
|
|
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
|
|
import { uploadFile } from "~/lib/arcadia/digital-objects"
|
|
import {
|
|
getProfile,
|
|
updateProfile as updateArcadiaProfile,
|
|
pickAvatarUrl,
|
|
type Profile as ArcadiaProfile,
|
|
} from "~/lib/arcadia/profiles"
|
|
import { updateSessionUser, useSession } from "~/lib/session"
|
|
|
|
export const meta = () => pageTitle("Profile")
|
|
|
|
interface AccountDraft {
|
|
first_name: string
|
|
last_name: string
|
|
email: string
|
|
}
|
|
|
|
export default function ProfileRoute() {
|
|
const session = useSession()
|
|
const arcadia = useArcadiaClient()
|
|
const profile = useProfile()
|
|
const agents = useAgents()
|
|
|
|
// Local preferences (avatar, title, bio, signature, default agent).
|
|
const [prefs, setPrefs] = useState<Profile>(profile)
|
|
const [prefsSavedAt, setPrefsSavedAt] = useState<number | null>(null)
|
|
useEffect(() => {
|
|
setPrefs(profile)
|
|
}, [profile])
|
|
const prefsDirty = JSON.stringify(prefs) !== JSON.stringify(profile)
|
|
|
|
// Arcadia account.
|
|
const [account, setAccount] = useState<User | null>(null)
|
|
const [accountDraft, setAccountDraft] = useState<AccountDraft>({
|
|
first_name: "",
|
|
last_name: "",
|
|
email: "",
|
|
})
|
|
const [accountLoading, setAccountLoading] = useState(true)
|
|
const [accountSaving, setAccountSaving] = useState(false)
|
|
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
|
|
const [accountError, setAccountError] = useState<string | null>(null)
|
|
|
|
// Server-side profile (avatar lives here — `prefs.avatarUrl` mirrors
|
|
// the resolved URL so the existing <AvatarImage> bindings keep working).
|
|
const [arcadiaProfile, setArcadiaProfile] = useState<ArcadiaProfile | null>(null)
|
|
const [avatarUploading, setAvatarUploading] = useState(false)
|
|
const [avatarError, setAvatarError] = useState<string | null>(null)
|
|
|
|
// Public-profile editable fields, server-backed via PATCH /api/v1/profile.
|
|
const [profileDraft, setProfileDraft] = useState<{
|
|
bio: string
|
|
phone: string
|
|
location: string
|
|
timezone: string
|
|
}>({ bio: "", phone: "", location: "", timezone: "" })
|
|
const [profileSaving, setProfileSaving] = useState(false)
|
|
const [profileSavedAt, setProfileSavedAt] = useState<number | null>(null)
|
|
const [profileError, setProfileError] = useState<string | null>(null)
|
|
|
|
const profileDirty =
|
|
!!arcadiaProfile &&
|
|
(profileDraft.bio !== (arcadiaProfile.bio ?? "") ||
|
|
profileDraft.phone !== (arcadiaProfile.phone ?? "") ||
|
|
profileDraft.location !== (arcadiaProfile.location ?? "") ||
|
|
profileDraft.timezone !== (arcadiaProfile.timezone ?? ""))
|
|
|
|
const loadAccount = useCallback(async () => {
|
|
if (!session) return
|
|
setAccountLoading(true)
|
|
setAccountError(null)
|
|
try {
|
|
const [u, p] = await Promise.all([
|
|
getUser(arcadia, session.userId),
|
|
getProfile(arcadia).catch(() => null),
|
|
])
|
|
setAccount(u)
|
|
setAccountDraft({
|
|
first_name: u.first_name ?? "",
|
|
last_name: u.last_name ?? "",
|
|
email: u.email,
|
|
})
|
|
if (p) {
|
|
setArcadiaProfile(p)
|
|
setProfileDraft({
|
|
bio: p.bio ?? "",
|
|
phone: p.phone ?? "",
|
|
location: p.location ?? "",
|
|
timezone: p.timezone ?? "",
|
|
})
|
|
const url = pickAvatarUrl(p)
|
|
if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
|
|
}
|
|
} catch (err) {
|
|
setAccountError(
|
|
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
|
)
|
|
} finally {
|
|
setAccountLoading(false)
|
|
}
|
|
}, [arcadia, session])
|
|
|
|
useEffect(() => {
|
|
loadAccount()
|
|
}, [loadAccount])
|
|
|
|
const accountDirty =
|
|
!!account &&
|
|
(accountDraft.first_name !== (account.first_name ?? "") ||
|
|
accountDraft.last_name !== (account.last_name ?? "") ||
|
|
accountDraft.email !== account.email)
|
|
|
|
const saveAccount = async () => {
|
|
if (!account) return
|
|
setAccountSaving(true)
|
|
setAccountError(null)
|
|
try {
|
|
const updated = await updateUser(arcadia, account.id, {
|
|
first_name: accountDraft.first_name || null,
|
|
last_name: accountDraft.last_name || null,
|
|
email: accountDraft.email,
|
|
})
|
|
setAccount(updated)
|
|
updateSessionUser({ name: updated.full_name, email: updated.email })
|
|
setAccountSavedAt(Date.now())
|
|
} catch (err) {
|
|
setAccountError(
|
|
err instanceof ArcadiaError ? err.message : "Save failed.",
|
|
)
|
|
} finally {
|
|
setAccountSaving(false)
|
|
}
|
|
}
|
|
|
|
const saveArcadiaProfile = async () => {
|
|
setProfileSaving(true)
|
|
setProfileError(null)
|
|
try {
|
|
const updated = await updateArcadiaProfile(arcadia, {
|
|
bio: profileDraft.bio || null,
|
|
phone: profileDraft.phone || null,
|
|
location: profileDraft.location || null,
|
|
timezone: profileDraft.timezone || null,
|
|
})
|
|
setArcadiaProfile(updated)
|
|
setProfileDraft({
|
|
bio: updated.bio ?? "",
|
|
phone: updated.phone ?? "",
|
|
location: updated.location ?? "",
|
|
timezone: updated.timezone ?? "",
|
|
})
|
|
setProfileSavedAt(Date.now())
|
|
} catch (err) {
|
|
setProfileError(
|
|
err instanceof ArcadiaError
|
|
? err.message
|
|
: err instanceof Error
|
|
? err.message
|
|
: "Save failed.",
|
|
)
|
|
} finally {
|
|
setProfileSaving(false)
|
|
}
|
|
}
|
|
|
|
// Local prefs handlers.
|
|
const initials = profileInitials(
|
|
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
|
account?.full_name ||
|
|
session?.name ||
|
|
"",
|
|
)
|
|
|
|
const onPickAvatar = async (file: File | null) => {
|
|
setAvatarError(null)
|
|
|
|
if (!file) {
|
|
// Clear: detach the digital object on the server, then drop the
|
|
// local cache. Keep the local cache cleared even if the server call
|
|
// fails so the UI reflects the user's intent.
|
|
setPrefs((d) => ({ ...d, avatarUrl: "" }))
|
|
try {
|
|
const updated = await updateArcadiaProfile(arcadia, {
|
|
avatar_digital_object_id: null,
|
|
})
|
|
setArcadiaProfile(updated)
|
|
savePrefsLocal({ ...prefs, avatarUrl: "" })
|
|
} catch (err) {
|
|
setAvatarError(
|
|
err instanceof Error ? err.message : "Failed to clear avatar.",
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!file.type.startsWith("image/")) {
|
|
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
|
|
return
|
|
}
|
|
// 8MB hard cap client-side; arcadia will enforce its own quota too.
|
|
if (file.size > 8 * 1024 * 1024) {
|
|
setAvatarError("Avatar is too large (max 8MB).")
|
|
return
|
|
}
|
|
|
|
setAvatarUploading(true)
|
|
try {
|
|
const obj = await uploadFile(arcadia, file, { tags: ["avatar"] })
|
|
const updated = await updateArcadiaProfile(arcadia, {
|
|
avatar_digital_object_id: obj.id,
|
|
})
|
|
setArcadiaProfile(updated)
|
|
const url = pickAvatarUrl(updated)
|
|
if (url) {
|
|
const next = { ...prefs, avatarUrl: url }
|
|
setPrefs(next)
|
|
savePrefsLocal(next)
|
|
}
|
|
} catch (err) {
|
|
setAvatarError(
|
|
err instanceof Error ? err.message : "Avatar upload failed.",
|
|
)
|
|
} finally {
|
|
setAvatarUploading(false)
|
|
}
|
|
}
|
|
|
|
// Save the local-prefs mirror (separate from `savePrefs`, which only
|
|
// runs on the explicit "Save" button — avatar changes auto-persist
|
|
// because the server already accepted them).
|
|
const savePrefsLocal = (next: Profile) => {
|
|
saveProfile(next)
|
|
}
|
|
|
|
const savePrefs = () => {
|
|
saveProfile(prefs)
|
|
setPrefsSavedAt(Date.now())
|
|
}
|
|
|
|
const defaultAgent = agents.find((a) => a.id === prefs.defaultAgentId) ?? null
|
|
|
|
return (
|
|
<AppShell>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-3">
|
|
Account
|
|
{account?.email_verified ? (
|
|
<Badge variant="default">Verified</Badge>
|
|
) : account ? (
|
|
<Badge variant="secondary">Unverified</Badge>
|
|
) : null}
|
|
{account?.status && account.status !== "active" ? (
|
|
<Badge variant="destructive">{account.status}</Badge>
|
|
) : null}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Your arcadia identity. Changes are saved to the platform and reflected
|
|
anywhere your name or email appears.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-6">
|
|
{accountError ? (
|
|
<AlertBanner
|
|
variant="error"
|
|
dismissible
|
|
onDismiss={() => setAccountError(null)}
|
|
>
|
|
{accountError}
|
|
</AlertBanner>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<Avatar className="size-20 ring-2 ring-primary/30">
|
|
{prefs.avatarUrl ? (
|
|
<AvatarImage src={prefs.avatarUrl} alt={accountDraft.email} />
|
|
) : null}
|
|
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col gap-1 text-sm">
|
|
<span className="font-medium">
|
|
{account?.full_name || accountDraft.email || "—"}
|
|
</span>
|
|
{account ? (
|
|
<>
|
|
<span className="text-xs text-muted-foreground">
|
|
Tenant <code className="font-mono">{account.tenant_id}</code> ·
|
|
ID <code className="font-mono">{account.id}</code>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Last sign-in{" "}
|
|
{account.last_sign_in_at
|
|
? new Date(account.last_sign_in_at).toLocaleString()
|
|
: "—"}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="First name">
|
|
<Input
|
|
data-action="profile-first-name"
|
|
value={accountDraft.first_name}
|
|
onChange={(e) =>
|
|
setAccountDraft((d) => ({ ...d, first_name: e.target.value }))
|
|
}
|
|
autoComplete="given-name"
|
|
disabled={accountLoading || accountSaving}
|
|
/>
|
|
</Field>
|
|
<Field label="Last name">
|
|
<Input
|
|
data-action="profile-last-name"
|
|
value={accountDraft.last_name}
|
|
onChange={(e) =>
|
|
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
|
|
}
|
|
autoComplete="family-name"
|
|
disabled={accountLoading || accountSaving}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Email"
|
|
hint="Updating your email may require re-verification."
|
|
>
|
|
<Input
|
|
data-action="profile-email"
|
|
type="email"
|
|
value={accountDraft.email}
|
|
onChange={(e) =>
|
|
setAccountDraft((d) => ({ ...d, email: e.target.value }))
|
|
}
|
|
autoComplete="email"
|
|
disabled={accountLoading || accountSaving}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
data-action="profile-account-save"
|
|
onClick={saveAccount}
|
|
disabled={!accountDirty || accountSaving || accountLoading}
|
|
>
|
|
{accountSaving ? (
|
|
<RefreshCw className="size-4 animate-spin" />
|
|
) : null}
|
|
Save account
|
|
</Button>
|
|
<Button
|
|
data-action="profile-account-revert"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (!account) return
|
|
setAccountDraft({
|
|
first_name: account.first_name ?? "",
|
|
last_name: account.last_name ?? "",
|
|
email: account.email,
|
|
})
|
|
}}
|
|
disabled={!accountDirty || accountSaving}
|
|
>
|
|
Revert
|
|
</Button>
|
|
<Button
|
|
data-action="profile-account-refresh"
|
|
variant="ghost"
|
|
onClick={loadAccount}
|
|
disabled={accountLoading}
|
|
>
|
|
<RefreshCw
|
|
className={accountLoading ? "size-4 animate-spin" : "size-4"}
|
|
/>
|
|
Refresh
|
|
</Button>
|
|
{accountSavedAt && !accountDirty && (
|
|
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
|
<Check className="size-4" /> Saved.
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Profile</CardTitle>
|
|
<CardDescription>
|
|
Public profile fields stored on arcadia. Visible to other members
|
|
of this tenant.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
{profileError ? (
|
|
<AlertBanner variant="error">{profileError}</AlertBanner>
|
|
) : null}
|
|
<Field
|
|
label="Bio"
|
|
hint="A short blurb about you."
|
|
>
|
|
<Textarea
|
|
data-action="profile-bio"
|
|
value={profileDraft.bio}
|
|
onChange={(e) =>
|
|
setProfileDraft((d) => ({ ...d, bio: e.target.value }))
|
|
}
|
|
rows={3}
|
|
placeholder="Tell others what you work on."
|
|
/>
|
|
</Field>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Phone">
|
|
<Input
|
|
data-action="profile-phone"
|
|
value={profileDraft.phone}
|
|
onChange={(e) =>
|
|
setProfileDraft((d) => ({ ...d, phone: e.target.value }))
|
|
}
|
|
placeholder="+61 …"
|
|
/>
|
|
</Field>
|
|
<Field label="Location">
|
|
<Input
|
|
data-action="profile-location"
|
|
value={profileDraft.location}
|
|
onChange={(e) =>
|
|
setProfileDraft((d) => ({ ...d, location: e.target.value }))
|
|
}
|
|
placeholder="Melbourne, AU"
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Timezone"
|
|
hint="IANA name (e.g. Australia/Melbourne)."
|
|
>
|
|
<Input
|
|
data-action="profile-timezone"
|
|
value={profileDraft.timezone}
|
|
onChange={(e) =>
|
|
setProfileDraft((d) => ({ ...d, timezone: e.target.value }))
|
|
}
|
|
placeholder="Australia/Melbourne"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
data-action="profile-arcadia-save"
|
|
onClick={saveArcadiaProfile}
|
|
disabled={!profileDirty || profileSaving}
|
|
>
|
|
{profileSaving ? "Saving…" : "Save profile"}
|
|
</Button>
|
|
<Button
|
|
data-action="profile-arcadia-revert"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (!arcadiaProfile) return
|
|
setProfileDraft({
|
|
bio: arcadiaProfile.bio ?? "",
|
|
phone: arcadiaProfile.phone ?? "",
|
|
location: arcadiaProfile.location ?? "",
|
|
timezone: arcadiaProfile.timezone ?? "",
|
|
})
|
|
}}
|
|
disabled={!profileDirty || profileSaving}
|
|
>
|
|
Revert
|
|
</Button>
|
|
{profileSavedAt && !profileDirty ? (
|
|
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
|
<Check className="size-4" /> Saved.
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Preferences</CardTitle>
|
|
<CardDescription>
|
|
Avatar uploads land in your tenant's storage backend; the rest
|
|
(title, signature, default persona) stays local to this browser.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-6">
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-medium">Avatar</span>
|
|
{avatarError ? (
|
|
<AlertBanner variant="error">{avatarError}</AlertBanner>
|
|
) : null}
|
|
<div className="flex items-center gap-3">
|
|
<label
|
|
aria-disabled={avatarUploading}
|
|
className={[
|
|
"inline-flex w-fit items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm",
|
|
avatarUploading
|
|
? "cursor-not-allowed opacity-60"
|
|
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
|
].join(" ")}
|
|
>
|
|
<input
|
|
data-action="profile-avatar-upload"
|
|
type="file"
|
|
accept="image/*"
|
|
className="sr-only"
|
|
disabled={avatarUploading}
|
|
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
|
/>
|
|
{avatarUploading ? "Uploading…" : "Upload avatar"}
|
|
</label>
|
|
{prefs.avatarUrl && !avatarUploading && (
|
|
<Button
|
|
data-action="profile-avatar-remove"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPickAvatar(null)}
|
|
className="text-muted-foreground"
|
|
>
|
|
<Trash2 className="size-3.5" /> Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
PNG, JPG, or SVG. Stored locally as a data URL.
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Title" hint="Your role at work.">
|
|
<Input
|
|
data-action="profile-title"
|
|
value={prefs.title}
|
|
onChange={(e) =>
|
|
setPrefs((d) => ({ ...d, title: e.target.value }))
|
|
}
|
|
placeholder="e.g. Platform admin"
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Default agent"
|
|
hint="Used as the active persona on first load."
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
data-action="profile-default-agent"
|
|
className="inline-flex h-9 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<span className="truncate">
|
|
{defaultAgent ? (
|
|
<>
|
|
<span className="font-medium">{defaultAgent.name}</span>
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
— {defaultAgent.role}
|
|
</span>
|
|
</>
|
|
) : (
|
|
"Use first available"
|
|
)}
|
|
</span>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-64">
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
setPrefs((d) => ({ ...d, defaultAgentId: "" }))
|
|
}
|
|
data-state={!prefs.defaultAgentId ? "checked" : undefined}
|
|
>
|
|
First available
|
|
</DropdownMenuItem>
|
|
{agents.map((a) => (
|
|
<DropdownMenuItem
|
|
key={a.id}
|
|
onClick={() =>
|
|
setPrefs((d) => ({ ...d, defaultAgentId: a.id }))
|
|
}
|
|
data-state={
|
|
prefs.defaultAgentId === a.id ? "checked" : undefined
|
|
}
|
|
className="flex flex-col items-start"
|
|
>
|
|
<span className="font-medium">{a.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{a.role}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</Field>
|
|
</div>
|
|
|
|
<Field
|
|
label="Signature"
|
|
hint="Appended automatically when you ask the assistant to draft an email or note."
|
|
>
|
|
<Textarea
|
|
data-action="profile-signature"
|
|
value={prefs.signature}
|
|
onChange={(e) =>
|
|
setPrefs((d) => ({ ...d, signature: e.target.value }))
|
|
}
|
|
rows={3}
|
|
placeholder={`Cheers,\n${account?.full_name || "Your name"}`}
|
|
/>
|
|
</Field>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
data-action="profile-prefs-save"
|
|
onClick={savePrefs}
|
|
disabled={!prefsDirty}
|
|
>
|
|
Save preferences
|
|
</Button>
|
|
<Button
|
|
data-action="profile-prefs-revert"
|
|
variant="ghost"
|
|
onClick={() => setPrefs(profile)}
|
|
disabled={!prefsDirty}
|
|
>
|
|
Revert
|
|
</Button>
|
|
<Button
|
|
data-action="profile-prefs-reset"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
resetProfile()
|
|
setPrefsSavedAt(Date.now())
|
|
}}
|
|
>
|
|
Reset to defaults
|
|
</Button>
|
|
{prefsSavedAt && !prefsDirty && (
|
|
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
|
<Check className="size-4" /> Saved.
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string
|
|
hint?: string
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-sm font-medium">{label}</span>
|
|
{children}
|
|
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
|
|
</label>
|
|
)
|
|
}
|