profile: real avatar upload + storage form fix
- Add a digital-objects client (uploadFile: open session → PUT to presigned URL → complete) and a profile client (getProfile, updateProfile, pickAvatarUrl variant resolver). - Wire profile.tsx avatar upload to use the real flow: validate image+size, upload to digital_objects tagged "avatar", PATCH /api/v1/profile with avatar_digital_object_id, mirror the resolved URL into local prefs so the existing <AvatarImage> binding keeps working. Show Uploading… state and an inline error banner. Clear detaches via avatar_digital_object_id: null. - Fix the storage form sending the wrong field name for the local backend — arcadia's StorageConfig changeset requires `base_path`, not `path`. The 422 was silent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,13 @@ import {
|
||||
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")
|
||||
@@ -69,18 +76,32 @@ export default function ProfileRoute() {
|
||||
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)
|
||||
|
||||
const loadAccount = useCallback(async () => {
|
||||
if (!session) return
|
||||
setAccountLoading(true)
|
||||
setAccountError(null)
|
||||
try {
|
||||
const u = await getUser(arcadia, session.userId)
|
||||
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)
|
||||
const url = pickAvatarUrl(p)
|
||||
if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
|
||||
}
|
||||
} catch (err) {
|
||||
setAccountError(
|
||||
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
||||
@@ -130,18 +151,65 @@ export default function ProfileRoute() {
|
||||
"",
|
||||
)
|
||||
|
||||
const onPickAvatar = (file: File | null) => {
|
||||
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
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result === "string")
|
||||
setPrefs((d) => ({ ...d, avatarUrl: result }))
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
|
||||
return
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
// 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 = () => {
|
||||
@@ -302,25 +370,37 @@ export default function ProfileRoute() {
|
||||
<CardHeader>
|
||||
<CardTitle>Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Local-only settings stored in this browser — avatar, bio, signature,
|
||||
and the assistant's default persona.
|
||||
Avatar uploads land in your tenant's storage backend; the rest
|
||||
(bio, 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 className="inline-flex w-fit cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground">
|
||||
<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)}
|
||||
/>
|
||||
Upload avatar
|
||||
{avatarUploading ? "Uploading…" : "Upload avatar"}
|
||||
</label>
|
||||
{prefs.avatarUrl && (
|
||||
{prefs.avatarUrl && !avatarUploading && (
|
||||
<Button
|
||||
data-action="profile-avatar-remove"
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user