profile: wire bio/phone/location/timezone to arcadia
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>
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
// Local user preferences — title, bio, signature, avatar, default agent.
|
// Local user preferences — title, signature, default agent, plus a cache
|
||||||
// Persisted in localStorage; reactive across tabs. Identity (name, email)
|
// mirror of the resolved avatar URL. Persisted in localStorage; reactive
|
||||||
// is owned by the arcadia session, not this store — see ~/lib/session.ts.
|
// across tabs. Identity (name, email) is owned by the arcadia session
|
||||||
|
// (~/lib/session.ts); the public profile (bio, phone, location, timezone)
|
||||||
|
// is server-backed via /api/v1/profile (~/lib/arcadia/profiles.ts).
|
||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export type Profile = {
|
export type Profile = {
|
||||||
title: string
|
title: string
|
||||||
bio: string
|
|
||||||
signature: string
|
signature: string
|
||||||
avatarUrl: string
|
avatarUrl: string
|
||||||
defaultAgentId: string
|
defaultAgentId: string
|
||||||
@@ -14,7 +15,6 @@ export type Profile = {
|
|||||||
|
|
||||||
export const DEFAULT_PROFILE: Profile = {
|
export const DEFAULT_PROFILE: Profile = {
|
||||||
title: "",
|
title: "",
|
||||||
bio: "",
|
|
||||||
signature: "",
|
signature: "",
|
||||||
avatarUrl: "",
|
avatarUrl: "",
|
||||||
defaultAgentId: "",
|
defaultAgentId: "",
|
||||||
@@ -32,7 +32,6 @@ function readFromStorage(): Profile {
|
|||||||
return {
|
return {
|
||||||
title:
|
title:
|
||||||
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
|
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
|
||||||
bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio,
|
|
||||||
signature:
|
signature:
|
||||||
typeof parsed.signature === "string"
|
typeof parsed.signature === "string"
|
||||||
? parsed.signature
|
? parsed.signature
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ export default function ProfileRoute() {
|
|||||||
const [avatarUploading, setAvatarUploading] = useState(false)
|
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
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 () => {
|
const loadAccount = useCallback(async () => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
setAccountLoading(true)
|
setAccountLoading(true)
|
||||||
@@ -99,6 +117,12 @@ export default function ProfileRoute() {
|
|||||||
})
|
})
|
||||||
if (p) {
|
if (p) {
|
||||||
setArcadiaProfile(p)
|
setArcadiaProfile(p)
|
||||||
|
setProfileDraft({
|
||||||
|
bio: p.bio ?? "",
|
||||||
|
phone: p.phone ?? "",
|
||||||
|
location: p.location ?? "",
|
||||||
|
timezone: p.timezone ?? "",
|
||||||
|
})
|
||||||
const url = pickAvatarUrl(p)
|
const url = pickAvatarUrl(p)
|
||||||
if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
|
if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
|
||||||
}
|
}
|
||||||
@@ -143,6 +167,37 @@ export default function ProfileRoute() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Local prefs handlers.
|
||||||
const initials = profileInitials(
|
const initials = profileInitials(
|
||||||
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
||||||
@@ -366,12 +421,106 @@ export default function ProfileRoute() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Preferences</CardTitle>
|
<CardTitle>Preferences</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Avatar uploads land in your tenant's storage backend; the rest
|
Avatar uploads land in your tenant's storage backend; the rest
|
||||||
(bio, signature, default persona) stays local to this browser.
|
(title, signature, default persona) stays local to this browser.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6">
|
<CardContent className="flex flex-col gap-6">
|
||||||
@@ -482,19 +631,6 @@ export default function ProfileRoute() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
|
||||||
label="Bio"
|
|
||||||
hint="A short blurb the assistant can reference (e.g. 'I work mostly in TypeScript')."
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
data-action="profile-bio"
|
|
||||||
value={prefs.bio}
|
|
||||||
onChange={(e) => setPrefs((d) => ({ ...d, bio: e.target.value }))}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Tell the assistant about you."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Signature"
|
label="Signature"
|
||||||
hint="Appended automatically when you ask the assistant to draft an email or note."
|
hint="Appended automatically when you ask the assistant to draft an email or note."
|
||||||
|
|||||||
Reference in New Issue
Block a user