diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 297a90f..b25fa1a 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -532,7 +532,11 @@ export function AppShell({ > {profile.avatarUrl ? ( - + ) : null} {user.initials} diff --git a/app/lib/arcadia/digital-objects.ts b/app/lib/arcadia/digital-objects.ts index ce5627c..014fc51 100644 --- a/app/lib/arcadia/digital-objects.ts +++ b/app/lib/arcadia/digital-objects.ts @@ -86,3 +86,45 @@ export async function deleteDigitalObject( ): Promise { 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 ``. 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 { + const headers: Record = { + 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) +} diff --git a/app/lib/arcadia/profiles.ts b/app/lib/arcadia/profiles.ts index 1f00906..2aac4d9 100644 --- a/app/lib/arcadia/profiles.ts +++ b/app/lib/arcadia/profiles.ts @@ -52,18 +52,24 @@ export async function updateProfile( } /** - * Pick the most appropriate avatar URL from a profile. Prefers a small - * variant (thumbnail / small / medium) if available; falls back to - * `avatar_url`, then null. + * Pick the most appropriate avatar URL from a profile. Backend returns + * `avatar_urls = {small, medium, large, original}` keyed by size. The + * variants are populated async after image processing completes — + * before that, all four are `null` and we fall back to the legacy + * `avatar_url` string column (which is also usually null when uploads + * use the digital_object pipeline). + * + * Returns null when nothing is ready; caller should fall back to + * fetching the raw content as a blob URL. */ export function pickAvatarUrl(profile: Profile | null | undefined): string | null { if (!profile) return null const variants = profile.avatar_urls if (variants && typeof variants === "object") { return ( - variants.thumbnail || variants.small || variants.medium || + variants.large || variants.original || profile.avatar_url || null diff --git a/app/lib/profile-bootstrap.tsx b/app/lib/profile-bootstrap.tsx index 323b8c1..6d1a342 100644 --- a/app/lib/profile-bootstrap.tsx +++ b/app/lib/profile-bootstrap.tsx @@ -5,6 +5,7 @@ import { useEffect } from "react" import { useArcadiaClient } from "@crema/arcadia-client" +import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects" import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles" import { loadProfile, saveProfile } from "~/lib/profile" @@ -23,11 +24,53 @@ export function ProfileBootstrap() { try { const p = await getProfile(arcadia) if (cancelled) return - const url = pickAvatarUrl(p) - if (!url) return + + const persistentUrl = pickAvatarUrl(p) const current = loadProfile() - if (current.avatarUrl === url) return - saveProfile({ ...current, avatarUrl: url }) + const cachedIsStaleBlob = current.avatarUrl?.startsWith("blob:") ?? false + + if (persistentUrl) { + if (current.avatarUrl !== persistentUrl) { + saveProfile({ ...current, avatarUrl: persistentUrl }) + } + return + } + + // No persistent variant yet but the user has an avatar — fetch + // the raw bytes as a blob URL. This also covers the "stale blob + // URL from previous session" case: replace it with a fresh one. + if (p.avatar_digital_object_id) { + if (cachedIsStaleBlob) { + // Clear the stale URL immediately so the appbar drops back + // to initials while we refetch (better than a broken image). + saveProfile({ ...current, avatarUrl: "" }) + } + try { + const baseUrl = + (import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? + "http://localhost:4000" + const tenantId = + (import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? + "default" + const blobUrl = await fetchDigitalObjectAsBlobUrl( + baseUrl, + p.avatar_digital_object_id, + token, + tenantId, + ) + if (cancelled) return + const fresh = loadProfile() + saveProfile({ ...fresh, avatarUrl: blobUrl }) + } catch { + // Best-effort; appbar will show initials until processing completes. + } + return + } + + // No avatar at all — clear any stale URL the cache might still hold. + if (current.avatarUrl) { + saveProfile({ ...current, avatarUrl: "" }) + } } catch { // 401 / network — silently skip; will retry on next session change. } diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index 76f89af..441e6b6 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -25,7 +25,10 @@ import { type Profile, } from "~/lib/profile" import { getUser, updateUser, type User } from "~/lib/arcadia/users" -import { uploadFile } from "~/lib/arcadia/digital-objects" +import { + fetchDigitalObjectAsBlobUrl, + uploadFile, +} from "~/lib/arcadia/digital-objects" import { getProfile, updateProfile as updateArcadiaProfile, @@ -246,11 +249,49 @@ export default function ProfileRoute() { avatar_digital_object_id: obj.id, }) setArcadiaProfile(updated) - const url = pickAvatarUrl(updated) - if (url) { - const next = { ...prefs, avatarUrl: url } + const persistentUrl = pickAvatarUrl(updated) + if (persistentUrl) { + // Variant pipeline already finished — persist to localStorage. + const next = { ...prefs, avatarUrl: persistentUrl } setPrefs(next) savePrefsLocal(next) + } else { + // Variants aren't ready yet (image-processing is async). Fetch + // the raw object as a blob URL for immediate in-memory render. + // Don't persist to localStorage — blob URLs don't survive a + // reload, and ProfileBootstrap will pick up the persistent URL + // on next mount once processing completes. + const token = + typeof window !== "undefined" + ? sessionStorage.getItem("arcadia_access_token") + : null + if (token) { + const baseUrl = + (import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? + "http://localhost:4000" + const tenantId = + (import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? + "default" + try { + const blobUrl = await fetchDigitalObjectAsBlobUrl( + baseUrl, + obj.id, + token, + tenantId, + ) + // eslint-disable-next-line no-console + console.info("[avatar] blob URL ready:", blobUrl) + // Persist the blob URL so the appbar's useProfile() picks it + // up via the storage event. Blob URLs don't survive a reload, + // but ProfileBootstrap will refresh on next mount. + const next = { ...prefs, avatarUrl: blobUrl } + setPrefs(next) + savePrefsLocal(next) + } catch (e) { + // eslint-disable-next-line no-console + console.error("[avatar] blob fetch failed:", e) + } + } } } catch (err) { setAvatarError( @@ -300,7 +341,11 @@ export default function ProfileRoute() {
{prefs.avatarUrl ? ( - + ) : null} {initials}