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:
@@ -82,6 +82,24 @@ export default function ProfileRoute() {
|
||||
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)
|
||||
@@ -99,6 +117,12 @@ export default function ProfileRoute() {
|
||||
})
|
||||
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 }))
|
||||
}
|
||||
@@ -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.
|
||||
const initials = profileInitials(
|
||||
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
||||
@@ -366,12 +421,106 @@ export default function ProfileRoute() {
|
||||
</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
|
||||
(bio, signature, default persona) stays local to this browser.
|
||||
(title, signature, default persona) stays local to this browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
@@ -482,19 +631,6 @@ export default function ProfileRoute() {
|
||||
</Field>
|
||||
</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
|
||||
label="Signature"
|
||||
hint="Appended automatically when you ask the assistant to draft an email or note."
|
||||
|
||||
Reference in New Issue
Block a user