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:
88
app/lib/arcadia/digital-objects.ts
Normal file
88
app/lib/arcadia/digital-objects.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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)}`)
|
||||
}
|
||||
73
app/lib/arcadia/profiles.ts
Normal file
73
app/lib/arcadia/profiles.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Arcadia profile API. Backed by /api/v1/profile (current user) — handles
|
||||
// avatar wiring (avatar_digital_object_id + variant URLs) and the basic
|
||||
// profile fields. The "profile" here is the per-tenant profile row, not
|
||||
// the auth account.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
user_id: string
|
||||
tenant_id: string
|
||||
avatar_url: string | null
|
||||
avatar_digital_object_id: string | null
|
||||
/**
|
||||
* Variant URLs keyed by size (e.g. "thumbnail", "medium", "original").
|
||||
* Shape depends on the storage backend; treat as best-effort.
|
||||
*/
|
||||
avatar_urls?: Record<string, string> | null
|
||||
bio?: string | null
|
||||
phone?: string | null
|
||||
location?: string | null
|
||||
timezone?: string | null
|
||||
inserted_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ProfileUpdateInput {
|
||||
avatar_digital_object_id?: string | null
|
||||
bio?: string | null
|
||||
phone?: string | null
|
||||
location?: string | null
|
||||
timezone?: string | null
|
||||
}
|
||||
|
||||
export async function getProfile(arcadia: ArcadiaClient): Promise<Profile> {
|
||||
const res = await arcadia.GET<{ data: Profile } | Profile>("/api/v1/profile")
|
||||
return "data" in (res as object)
|
||||
? (res as { data: Profile }).data
|
||||
: (res as Profile)
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ProfileUpdateInput,
|
||||
): Promise<Profile> {
|
||||
const res = await arcadia.PATCH<{ data: Profile } | Profile>("/api/v1/profile", {
|
||||
body: { profile: input },
|
||||
})
|
||||
return "data" in (res as object)
|
||||
? (res as { data: Profile }).data
|
||||
: (res as Profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.original ||
|
||||
profile.avatar_url ||
|
||||
null
|
||||
)
|
||||
}
|
||||
return profile.avatar_url ?? null
|
||||
}
|
||||
@@ -149,7 +149,9 @@ export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
||||
gcs: ["bucket", "service_account_json"],
|
||||
local: ["path"],
|
||||
// Local backend's filesystem root. Backend changeset rejects "path" — must
|
||||
// be `base_path`. Keep this in sync with `Arcadia.Storage.Adapters.Local`.
|
||||
local: ["base_path"],
|
||||
}
|
||||
|
||||
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
|
||||
Reference in New Issue
Block a user