diff --git a/app/lib/arcadia/digital-objects.ts b/app/lib/arcadia/digital-objects.ts new file mode 100644 index 0000000..ce5627c --- /dev/null +++ b/app/lib/arcadia/digital-objects.ts @@ -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 + 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 { + 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 { + await arcadia.DELETE(`/api/v1/digital_objects/${encodeURIComponent(id)}`) +} diff --git a/app/lib/arcadia/profiles.ts b/app/lib/arcadia/profiles.ts new file mode 100644 index 0000000..1f00906 --- /dev/null +++ b/app/lib/arcadia/profiles.ts @@ -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 | 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 { + 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 { + 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 +} diff --git a/app/lib/arcadia/storage-configs.ts b/app/lib/arcadia/storage-configs.ts index 8066d7a..43dd9f7 100644 --- a/app/lib/arcadia/storage-configs.ts +++ b/app/lib/arcadia/storage-configs.ts @@ -149,7 +149,9 @@ export const SECRET_FIELDS: Record = { export const REQUIRED_FIELDS: Record = { 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 = { diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index 002f344..d12a620 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -33,6 +33,13 @@ import { type Profile, } from "~/lib/profile" import { getUser, updateUser, type User } from "~/lib/arcadia/users" +import { uploadFile } from "~/lib/arcadia/digital-objects" +import { + getProfile, + updateProfile as updateArcadiaProfile, + pickAvatarUrl, + type Profile as ArcadiaProfile, +} from "~/lib/arcadia/profiles" import { updateSessionUser, useSession } from "~/lib/session" export const meta = () => pageTitle("Profile") @@ -69,18 +76,32 @@ export default function ProfileRoute() { const [accountSavedAt, setAccountSavedAt] = useState(null) const [accountError, setAccountError] = useState(null) + // Server-side profile (avatar lives here — `prefs.avatarUrl` mirrors + // the resolved URL so the existing bindings keep working). + const [arcadiaProfile, setArcadiaProfile] = useState(null) + const [avatarUploading, setAvatarUploading] = useState(false) + const [avatarError, setAvatarError] = useState(null) + const loadAccount = useCallback(async () => { if (!session) return setAccountLoading(true) setAccountError(null) try { - const u = await getUser(arcadia, session.userId) + const [u, p] = await Promise.all([ + getUser(arcadia, session.userId), + getProfile(arcadia).catch(() => null), + ]) setAccount(u) setAccountDraft({ first_name: u.first_name ?? "", last_name: u.last_name ?? "", email: u.email, }) + if (p) { + setArcadiaProfile(p) + const url = pickAvatarUrl(p) + if (url) setPrefs((d) => ({ ...d, avatarUrl: url })) + } } catch (err) { setAccountError( err instanceof ArcadiaError ? err.message : "Failed to load account.", @@ -130,18 +151,65 @@ export default function ProfileRoute() { "", ) - const onPickAvatar = (file: File | null) => { + const onPickAvatar = async (file: File | null) => { + setAvatarError(null) + if (!file) { + // Clear: detach the digital object on the server, then drop the + // local cache. Keep the local cache cleared even if the server call + // fails so the UI reflects the user's intent. setPrefs((d) => ({ ...d, avatarUrl: "" })) + try { + const updated = await updateArcadiaProfile(arcadia, { + avatar_digital_object_id: null, + }) + setArcadiaProfile(updated) + savePrefsLocal({ ...prefs, avatarUrl: "" }) + } catch (err) { + setAvatarError( + err instanceof Error ? err.message : "Failed to clear avatar.", + ) + } return } - const reader = new FileReader() - reader.onload = () => { - const result = reader.result - if (typeof result === "string") - setPrefs((d) => ({ ...d, avatarUrl: result })) + + if (!file.type.startsWith("image/")) { + setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).") + return } - reader.readAsDataURL(file) + // 8MB hard cap client-side; arcadia will enforce its own quota too. + if (file.size > 8 * 1024 * 1024) { + setAvatarError("Avatar is too large (max 8MB).") + return + } + + setAvatarUploading(true) + try { + const obj = await uploadFile(arcadia, file, { tags: ["avatar"] }) + const updated = await updateArcadiaProfile(arcadia, { + avatar_digital_object_id: obj.id, + }) + setArcadiaProfile(updated) + const url = pickAvatarUrl(updated) + if (url) { + const next = { ...prefs, avatarUrl: url } + setPrefs(next) + savePrefsLocal(next) + } + } catch (err) { + setAvatarError( + err instanceof Error ? err.message : "Avatar upload failed.", + ) + } finally { + setAvatarUploading(false) + } + } + + // Save the local-prefs mirror (separate from `savePrefs`, which only + // runs on the explicit "Save" button — avatar changes auto-persist + // because the server already accepted them). + const savePrefsLocal = (next: Profile) => { + saveProfile(next) } const savePrefs = () => { @@ -302,25 +370,37 @@ export default function ProfileRoute() { Preferences - Local-only settings stored in this browser — avatar, bio, signature, - and the assistant's default persona. + Avatar uploads land in your tenant's storage backend; the rest + (bio, signature, default persona) stays local to this browser.
Avatar + {avatarError ? ( + {avatarError} + ) : null}
-