/** * Media upload, list, delete, and provider APIs */ import { i18n } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { API_BASE, apiFetch, parseApiResponse, throwResponseError, type FindManyResult, } from "./client.js"; /** * Maximum length of the media filename search term. Mirrors the server-side * zod schema (`q: z.string().trim().min(1).max(200)`); keep in sync. */ export const MEDIA_SEARCH_MAX_LENGTH = 200; /** Trim and clamp a search term to the server-accepted range. */ export function normalizeMediaSearch(value: string | undefined | null): string { return (value ?? "").trim().slice(0, MEDIA_SEARCH_MAX_LENGTH); } export interface MediaItem { id: string; filename: string; mimeType: string; url: string; /** Storage key for local media (e.g., "01ABC.jpg"). Not present for external URLs. */ storageKey?: string; size: number; width?: number; height?: number; /** LQIP blurhash placeholder (images only) */ blurhash?: string; /** LQIP dominant-color placeholder, as a CSS color (images only) */ dominantColor?: string; alt?: string; caption?: string; createdAt: string; /** Provider ID for external media (e.g., "cloudflare-images") */ provider?: string; /** Provider-specific metadata */ meta?: Record; } /** * Fetch media list */ export async function fetchMediaList(options?: { cursor?: string; limit?: number; mimeType?: string | string[]; /** Case-insensitive filename substring search (also matches extensions). */ search?: string; }): Promise> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); if (options?.limit) params.set("limit", String(options.limit)); if (options?.mimeType) { const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; if (value) params.set("mimeType", value); } if (options?.search) { // Trim and clamp to the server's accepted range so a long or // whitespace-only term can't trigger an avoidable 400. const q = normalizeMediaSearch(options.search); if (q) params.set("q", q); } const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); return parseApiResponse>(response, i18n._(msg`Failed to fetch media`)); } /** * Fetch a single media item by id. * * Used to resolve an id-only reference (e.g. a byline's `avatarMediaId`) * back into a full media item for display. */ export async function fetchMediaItem(id: string): Promise { const response = await apiFetch(`${API_BASE}/media/${id}`); const data = await parseApiResponse<{ item: MediaItem }>( response, i18n._(msg`Failed to fetch media item`), ); return data.item; } /** * Upload URL response from the API */ interface UploadUrlResponse { uploadUrl: string; method: "PUT"; headers: Record; mediaId: string; storageKey: string; expiresAt: string; } /** * Try to get a signed upload URL * Returns null if signed URLs are not supported (e.g., local storage) */ async function getUploadUrl( file: File, opts?: { fieldId?: string }, ): Promise { try { const response = await apiFetch(`${API_BASE}/media/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size, ...(opts?.fieldId ? { fieldId: opts.fieldId } : {}), }), }); if (response.status === 501) { // Not implemented - storage doesn't support signed URLs return null; } return parseApiResponse(response, i18n._(msg`Failed to get upload URL`)); } catch (error) { // If the endpoint doesn't exist, fall back to direct upload if (error instanceof TypeError && error.message.includes("fetch")) { return null; } throw error; } } /** * Confirm upload after uploading to signed URL */ async function confirmUpload( mediaId: string, metadata?: { width?: number; height?: number; size?: number }, ): Promise { const response = await apiFetch(`${API_BASE}/media/${mediaId}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(metadata || {}), }); const data = await parseApiResponse<{ item: MediaItem }>( response, i18n._(msg`Failed to confirm upload`), ); return data.item; } /** * Upload directly to signed URL */ async function uploadToSignedUrl(file: File, uploadInfo: UploadUrlResponse): Promise { const response = await fetch(uploadInfo.uploadUrl, { method: uploadInfo.method, headers: { ...uploadInfo.headers, "Content-Type": file.type, }, body: file, }); if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to upload file`)); } /** * Get image dimensions from a file */ async function getImageDimensions(file: File): Promise<{ width: number; height: number } | null> { if (!file.type.startsWith("image/")) { return null; } return new Promise((resolve) => { const img = new Image(); img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }); URL.revokeObjectURL(img.src); }; img.onerror = () => { resolve(null); URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(file); }); } /** * Upload media file via direct upload (legacy/local storage) */ async function uploadMediaDirect(file: File, opts?: { fieldId?: string }): Promise { // Get image dimensions before upload const dimensions = await getImageDimensions(file); const formData = new FormData(); formData.append("file", file); // Send dimensions as form fields if (dimensions?.width) formData.append("width", String(dimensions.width)); if (dimensions?.height) formData.append("height", String(dimensions.height)); if (opts?.fieldId) formData.append("fieldId", opts.fieldId); const response = await apiFetch(`${API_BASE}/media`, { method: "POST", body: formData, }); const data = await parseApiResponse<{ item: MediaItem }>( response, i18n._(msg`Failed to upload media`), ); return data.item; } /** * Upload media file * * Tries signed URL upload first (for S3/R2 storage), falls back to direct upload * (for local storage) if signed URLs are not supported. */ export async function uploadMedia(file: File, opts?: { fieldId?: string }): Promise { // Try to get a signed upload URL const uploadInfo = await getUploadUrl(file, opts); if (!uploadInfo) { // Signed URLs not supported, use direct upload return uploadMediaDirect(file, opts); } // Upload directly to storage via signed URL await uploadToSignedUrl(file, uploadInfo); // Get image dimensions for confirmation const dimensions = await getImageDimensions(file); // Confirm the upload return confirmUpload(uploadInfo.mediaId, { size: file.size, width: dimensions?.width, height: dimensions?.height, }); } /** * Delete media */ export async function deleteMedia(id: string): Promise { const response = await apiFetch(`${API_BASE}/media/${id}`, { method: "DELETE", }); if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete media`)); } /** * Update media metadata (dimensions, alt text, etc.) */ export async function updateMedia( id: string, input: { alt?: string; caption?: string; width?: number; height?: number }, ): Promise { const response = await apiFetch(`${API_BASE}/media/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); const data = await parseApiResponse<{ item: MediaItem }>( response, i18n._(msg`Failed to update media`), ); return data.item; } // ============================================================================= // Media Providers API // ============================================================================= /** Media provider capabilities */ export interface MediaProviderCapabilities { browse: boolean; search: boolean; upload: boolean; delete: boolean; } /** Media provider info from the API */ export interface MediaProviderInfo { id: string; name: string; icon?: string; capabilities: MediaProviderCapabilities; } /** Media item from a provider */ export interface MediaProviderItem { id: string; filename: string; mimeType: string; size?: number; width?: number; height?: number; /** LQIP blurhash placeholder (images only) */ blurhash?: string; /** LQIP dominant-color placeholder, as a CSS color (images only) */ dominantColor?: string; alt?: string; previewUrl?: string; meta?: Record; } /** * Fetch all configured media providers */ export async function fetchMediaProviders(): Promise { const response = await apiFetch(`${API_BASE}/media/providers`); const data = await parseApiResponse<{ items: MediaProviderInfo[] }>( response, i18n._(msg`Failed to fetch media providers`), ); return data.items; } /** * Fetch media items from a specific provider */ export async function fetchProviderMedia( providerId: string, options?: { cursor?: string; limit?: number; query?: string; mimeType?: string | string[]; }, ): Promise> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); if (options?.limit) params.set("limit", String(options.limit)); if (options?.query) params.set("query", options.query); if (options?.mimeType) { const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; if (value) params.set("mimeType", value); } const url = `${API_BASE}/media/providers/${providerId}${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); return parseApiResponse>( response, i18n._(msg`Failed to fetch provider media`), ); } /** * Upload media to a specific provider */ export async function uploadToProvider( providerId: string, file: File, alt?: string, ): Promise { const formData = new FormData(); formData.append("file", file); if (alt) formData.append("alt", alt); const response = await apiFetch(`${API_BASE}/media/providers/${providerId}`, { method: "POST", body: formData, }); const data = await parseApiResponse<{ item: MediaProviderItem }>( response, i18n._(msg`Failed to upload to provider`), ); return data.item; } /** * Delete media from a specific provider */ export async function deleteFromProvider(providerId: string, itemId: string): Promise { const response = await apiFetch(`${API_BASE}/media/providers/${providerId}/${itemId}`, { method: "DELETE", }); if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete from provider`)); }