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}