These were aspirational placeholders — stored to localStorage but never read by anything. Removed from the form, types, and persistence layer. Local profile is now just the avatar URL mirror, which the appbar reads before the server profile fetch resolves on mount. Preferences card renamed to "Avatar" since that's all that's left. Re-add server-backed if/when something actually consumes them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
569 lines
18 KiB
TypeScript
569 lines
18 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 { Input } from "~/components/ui/input"
|
|
import { Textarea } from "~/components/ui/textarea"
|
|
import { pageTitle } from "~/lib/page-meta"
|
|
import {
|
|
profileInitials,
|
|
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()
|
|
|
|
// Mirror of the resolved avatar URL — kept in localStorage so the
|
|
// <AvatarImage> in the appbar can render before the profile fetch
|
|
// resolves on next mount.
|
|
const [prefs, setPrefs] = useState<Profile>(profile)
|
|
useEffect(() => {
|
|
setPrefs(profile)
|
|
}, [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)
|
|
}
|
|
}
|
|
|
|
// Mirror the avatar URL into localStorage so it survives reloads.
|
|
const savePrefsLocal = (next: Profile) => {
|
|
saveProfile(next)
|
|
}
|
|
|
|
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>Avatar</CardTitle>
|
|
<CardDescription>
|
|
Uploads land in your tenant's storage backend.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2">
|
|
{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, GIF, or WebP. Max 8MB.
|
|
</span>
|
|
</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>
|
|
)
|
|
}
|