avatar: render immediately + survive reload
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>
This commit is contained in:
@@ -532,7 +532,11 @@ export function AppShell({
|
|||||||
>
|
>
|
||||||
<Avatar className="size-7 cursor-pointer">
|
<Avatar className="size-7 cursor-pointer">
|
||||||
{profile.avatarUrl ? (
|
{profile.avatarUrl ? (
|
||||||
<AvatarImage src={profile.avatarUrl} alt={user.name} />
|
<AvatarImage
|
||||||
|
key={profile.avatarUrl}
|
||||||
|
src={profile.avatarUrl}
|
||||||
|
alt={user.name}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -86,3 +86,45 @@ export async function deleteDigitalObject(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await arcadia.DELETE(`/api/v1/digital_objects/${encodeURIComponent(id)}`)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,18 +52,24 @@ export async function updateProfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the most appropriate avatar URL from a profile. Prefers a small
|
* Pick the most appropriate avatar URL from a profile. Backend returns
|
||||||
* variant (thumbnail / small / medium) if available; falls back to
|
* `avatar_urls = {small, medium, large, original}` keyed by size. The
|
||||||
* `avatar_url`, then null.
|
* 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 {
|
export function pickAvatarUrl(profile: Profile | null | undefined): string | null {
|
||||||
if (!profile) return null
|
if (!profile) return null
|
||||||
const variants = profile.avatar_urls
|
const variants = profile.avatar_urls
|
||||||
if (variants && typeof variants === "object") {
|
if (variants && typeof variants === "object") {
|
||||||
return (
|
return (
|
||||||
variants.thumbnail ||
|
|
||||||
variants.small ||
|
variants.small ||
|
||||||
variants.medium ||
|
variants.medium ||
|
||||||
|
variants.large ||
|
||||||
variants.original ||
|
variants.original ||
|
||||||
profile.avatar_url ||
|
profile.avatar_url ||
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||||
|
|
||||||
|
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
|
||||||
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
||||||
import { loadProfile, saveProfile } from "~/lib/profile"
|
import { loadProfile, saveProfile } from "~/lib/profile"
|
||||||
|
|
||||||
@@ -23,11 +24,53 @@ export function ProfileBootstrap() {
|
|||||||
try {
|
try {
|
||||||
const p = await getProfile(arcadia)
|
const p = await getProfile(arcadia)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const url = pickAvatarUrl(p)
|
|
||||||
if (!url) return
|
const persistentUrl = pickAvatarUrl(p)
|
||||||
const current = loadProfile()
|
const current = loadProfile()
|
||||||
if (current.avatarUrl === url) return
|
const cachedIsStaleBlob = current.avatarUrl?.startsWith("blob:") ?? false
|
||||||
saveProfile({ ...current, avatarUrl: url })
|
|
||||||
|
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 {
|
} catch {
|
||||||
// 401 / network — silently skip; will retry on next session change.
|
// 401 / network — silently skip; will retry on next session change.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import {
|
|||||||
type Profile,
|
type Profile,
|
||||||
} from "~/lib/profile"
|
} from "~/lib/profile"
|
||||||
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
|
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 {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile as updateArcadiaProfile,
|
updateProfile as updateArcadiaProfile,
|
||||||
@@ -246,11 +249,49 @@ export default function ProfileRoute() {
|
|||||||
avatar_digital_object_id: obj.id,
|
avatar_digital_object_id: obj.id,
|
||||||
})
|
})
|
||||||
setArcadiaProfile(updated)
|
setArcadiaProfile(updated)
|
||||||
const url = pickAvatarUrl(updated)
|
const persistentUrl = pickAvatarUrl(updated)
|
||||||
if (url) {
|
if (persistentUrl) {
|
||||||
const next = { ...prefs, avatarUrl: url }
|
// Variant pipeline already finished — persist to localStorage.
|
||||||
|
const next = { ...prefs, avatarUrl: persistentUrl }
|
||||||
setPrefs(next)
|
setPrefs(next)
|
||||||
savePrefsLocal(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) {
|
} catch (err) {
|
||||||
setAvatarError(
|
setAvatarError(
|
||||||
@@ -300,7 +341,11 @@ export default function ProfileRoute() {
|
|||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Avatar className="size-20 ring-2 ring-primary/30">
|
<Avatar className="size-20 ring-2 ring-primary/30">
|
||||||
{prefs.avatarUrl ? (
|
{prefs.avatarUrl ? (
|
||||||
<AvatarImage src={prefs.avatarUrl} alt={accountDraft.email} />
|
<AvatarImage
|
||||||
|
key={prefs.avatarUrl}
|
||||||
|
src={prefs.avatarUrl}
|
||||||
|
alt={accountDraft.email}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
||||||
{initials}
|
{initials}
|
||||||
|
|||||||
Reference in New Issue
Block a user