import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui"; import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react"; import { createUploadQueue, type UploadFile, type UploadItem, type UploadQueue, } from "../../browser/uploads.js"; import type { SignageApi } from "../api.js"; import { useAction, useResource } from "../hooks.js"; import type { ImageRecord, SettingsResponse } from "../types.js"; import { ConfirmButton, FailureNote, formatInstant, renderLoadable } from "./shared.js"; /** * The native image library and its upload queue. * * The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty * individually bounded requests, a few at a time. The page stays responsive because only a few are * in flight; one bad file fails alone; and the operator learns about a file that is too large or of * the wrong type before a byte is sent, while the server still checks both again. */ export function ImagesPanel({ api, settings, canManage, }: { readonly api: SignageApi; readonly settings: SettingsResponse; readonly canManage: boolean; }): ReactElement { const images = useResource( () => api.get<{ images: readonly ImageRecord[] }>("images"), [], "images", ); const [uploads, setUploads] = useState([]); const queueRef = useRef(undefined); const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined); const rename = useAction(() => { setRenaming(undefined); images.reload(); }); const remove = useAction(() => images.reload()); const queue = useMemo(() => { const created = createUploadQueue({ concurrency: settings.uploads.concurrency, maxBytes: settings.uploads.maxBytes, maxFiles: settings.uploads.batchMaxFiles, onChange: (items) => setUploads(items), upload: async (file) => { // One image per request, with the label in the query string so the body is exactly the // bytes. The server streams and counts them; nothing here buffers a second copy. const query = new URLSearchParams({ displayName: file.name, filename: file.name }); await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type); }, }); queueRef.current = created; return created; }, [ api, settings.uploads.batchMaxFiles, settings.uploads.concurrency, settings.uploads.maxBytes, ]); const onFiles = useCallback( (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []) as unknown as UploadFile[]; if (files.length === 0) { return; } queue.enqueue(files); void queue.drain().then(() => images.reload()); event.target.value = ""; }, [images, queue], ); const finished = uploads.filter( (item) => item.status !== "queued" && item.status !== "uploading", ); return ( Add images ) : undefined } description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`} title="Images" > {uploads.length === 0 ? undefined : (
    {uploads.map((item) => (
  • {item.name} {item.status} {item.message === undefined ? undefined : ( {item.message} )}
  • ))}
{finished.length === 0 ? undefined : ( )}
)} {renderLoadable( images.state, (value) => value.images.length === 0 ? ( ) : (
    {value.images.map((image) => { const thumbnail = image.renditions.find( (rendition) => rendition.kind === "thumbnail", ); return (
  • {thumbnail === undefined ? (
    No preview
    ) : ( {image.displayName} )}
    {renaming?.id === image.id ? ( {(control) => ( setRenaming({ id: image.id, value: event.target.value }) } value={renaming.value} /> )} ) : (

    {image.displayName}

    )}

    {`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}

    {image.expiresAt === null ? undefined : ( {`Expires ${formatInstant(image.expiresAt, settings.timezone)}`} )}
    {canManage ? (
    {renaming?.id === image.id ? ( <> ) : ( )} remove.run(() => api.send("DELETE", `images/${image.id}`)) } testId={`signage-image-delete-${image.id}`} />
    ) : undefined}
  • ); })}
), { label: "Loading images…", onRetry: images.reload }, )}
); }