The variant pipeline is async, so right after upload all four URLs in
profile.avatar_urls are still null. The first wiring attempt called
pickAvatarUrl() which returned null, and nothing visible changed even
though the upload + PATCH succeeded.
Fixes:
- pickAvatarUrl: use the actual backend keys (small/medium/large/
original — there's no "thumbnail").
- After upload, when no variant URL is ready, fetch the raw object
via /api/v1/digital_objects/:id/content as a blob URL for immediate
display. Persist that URL to localStorage so the appbar's
useProfile() picks it up via the storage event.
- ProfileBootstrap: detect stale blob: URLs cached from previous
sessions, clear them, and refetch a fresh blob URL when variants
still aren't ready. Eventually the persistent variant URLs land
and overwrite.
- Force-remount AvatarImage via key={src} in the profile page and
appbar — base-ui's Avatar.Image keeps internal load state that
doesn't always reset on src change.
- Diagnostic logs in fetchDigitalObjectAsBlobUrl + the upload flow
to make next debug round one step easier (kept; cheap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
3.8 KiB
TypeScript
131 lines
3.8 KiB
TypeScript
// Arcadia digital objects API — minimal client covering the upload flow
|
|
// used by the avatar uploader. The full digital-objects API is much
|
|
// larger; add endpoints here as we wire more features.
|
|
|
|
import type { ArcadiaClient } from "@crema/arcadia-client"
|
|
|
|
export interface DigitalObject {
|
|
id: string
|
|
tenant_id?: string
|
|
user_id?: string
|
|
storage_config_id?: string
|
|
filename?: string
|
|
original_filename?: string
|
|
content_type?: string
|
|
size_bytes?: number
|
|
key?: string
|
|
object_key?: string
|
|
status?: "active" | "archived" | "deleted"
|
|
inserted_at?: string
|
|
}
|
|
|
|
interface UploadSession {
|
|
id: string
|
|
upload_url: string
|
|
presigned_url?: string
|
|
expires_at?: string
|
|
}
|
|
|
|
interface CreateUploadSessionInput {
|
|
filename: string
|
|
content_type: string
|
|
size_bytes: number
|
|
storage_config_id?: string
|
|
metadata?: Record<string, unknown>
|
|
tags?: string[]
|
|
}
|
|
|
|
/**
|
|
* Three-step upload: open a session, PUT the bytes to the presigned URL
|
|
* the session returns, complete the session to land the digital_object.
|
|
*
|
|
* Returns the finalized DigitalObject record.
|
|
*/
|
|
export async function uploadFile(
|
|
arcadia: ArcadiaClient,
|
|
file: File,
|
|
opts: { storage_config_id?: string; tags?: string[] } = {},
|
|
): Promise<DigitalObject> {
|
|
const sessionInput: CreateUploadSessionInput = {
|
|
filename: file.name,
|
|
content_type: file.type || "application/octet-stream",
|
|
size_bytes: file.size,
|
|
storage_config_id: opts.storage_config_id,
|
|
tags: opts.tags,
|
|
}
|
|
|
|
const session = await arcadia.POST<{ data: UploadSession }>(
|
|
"/api/v1/digital_objects/upload_sessions",
|
|
{ body: { upload_session: sessionInput } },
|
|
)
|
|
|
|
const uploadUrl = session.data.upload_url || session.data.presigned_url
|
|
if (!uploadUrl) throw new Error("Upload session returned no upload URL")
|
|
|
|
const putRes = await fetch(uploadUrl, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": sessionInput.content_type },
|
|
body: file,
|
|
})
|
|
if (!putRes.ok) {
|
|
throw new Error(
|
|
`Upload to storage failed: ${putRes.status} ${await putRes.text().catch(() => "")}`,
|
|
)
|
|
}
|
|
|
|
const completed = await arcadia.POST<{ data: DigitalObject }>(
|
|
`/api/v1/digital_objects/upload_sessions/${encodeURIComponent(session.data.id)}/complete`,
|
|
{ body: {} },
|
|
)
|
|
return completed.data
|
|
}
|
|
|
|
export async function deleteDigitalObject(
|
|
arcadia: ArcadiaClient,
|
|
id: string,
|
|
): Promise<void> {
|
|
await arcadia.DELETE(`/api/v1/digital_objects/${encodeURIComponent(id)}`)
|
|
}
|
|
|
|
/**
|
|
* Fetch the raw bytes of a digital object and return a browser blob URL
|
|
* suitable for `<img src>`. Used as an immediate-display fallback when
|
|
* the async variant URLs aren't ready yet (e.g. fresh avatar upload).
|
|
*
|
|
* Bypasses the arcadia-client because that client only parses JSON or
|
|
* text — for binary we need response.blob(). Auth is injected manually
|
|
* from sessionStorage to match the rest of the auth surface.
|
|
*
|
|
* The returned blob URL is per-page; it does NOT survive a reload.
|
|
* Caller should not persist it to localStorage — only render in memory
|
|
* until the persistent variant URLs come through (e.g. on next mount).
|
|
*/
|
|
export async function fetchDigitalObjectAsBlobUrl(
|
|
baseUrl: string,
|
|
id: string,
|
|
token: string,
|
|
tenantId?: string,
|
|
): Promise<string> {
|
|
const headers: Record<string, string> = {
|
|
Accept: "*/*",
|
|
Authorization: `Bearer ${token}`,
|
|
}
|
|
if (tenantId) headers["X-Tenant-ID"] = tenantId
|
|
|
|
const url = `${baseUrl.replace(/\/+$/, "")}/api/v1/digital_objects/${encodeURIComponent(
|
|
id,
|
|
)}/content`
|
|
const res = await fetch(url, { headers })
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`Failed to fetch digital object content: ${res.status} ${await res.text().catch(() => "")}`,
|
|
)
|
|
}
|
|
const blob = await res.blob()
|
|
// eslint-disable-next-line no-console
|
|
console.info(
|
|
`[digital-objects] fetched blob id=${id} type=${blob.type} size=${blob.size}B`,
|
|
)
|
|
return URL.createObjectURL(blob)
|
|
}
|