Files
arcadia-admin/app/lib/profile.ts
jules f6a92118da 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>
2026-05-05 09:49:34 +10:00

106 lines
2.8 KiB
TypeScript

// Local user preferences — title, signature, default agent, plus a cache
// mirror of the resolved avatar URL. Persisted in localStorage; reactive
// 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"
export type Profile = {
title: string
signature: string
avatarUrl: string
defaultAgentId: string
}
export const DEFAULT_PROFILE: Profile = {
title: "",
signature: "",
avatarUrl: "",
defaultAgentId: "",
}
const STORAGE_KEY = "crema.profile"
const CHANGE_EVENT = "crema:profile-change"
function readFromStorage(): Profile {
if (typeof window === "undefined") return DEFAULT_PROFILE
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_PROFILE
const parsed = JSON.parse(raw) as Partial<Profile>
return {
title:
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
signature:
typeof parsed.signature === "string"
? parsed.signature
: DEFAULT_PROFILE.signature,
avatarUrl:
typeof parsed.avatarUrl === "string"
? parsed.avatarUrl
: DEFAULT_PROFILE.avatarUrl,
defaultAgentId:
typeof parsed.defaultAgentId === "string"
? parsed.defaultAgentId
: DEFAULT_PROFILE.defaultAgentId,
}
} catch {
return DEFAULT_PROFILE
}
}
export function loadProfile(): Profile {
return readFromStorage()
}
export function saveProfile(next: Profile) {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function resetProfile() {
saveProfile(DEFAULT_PROFILE)
}
export function profileInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}
let cached: Profile | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Profile {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Profile {
return DEFAULT_PROFILE
}
export function useProfile(): Profile {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}