Files
Signage/src/ui/panels/ImagesPanel.tsx
T
2026-08-28 16:41:54 -05:00

227 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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"), []);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(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<HTMLInputElement>) => {
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 (
<Panel
actions={
canManage ? (
<label className="signage-upload-trigger">
<span className="ek-button ek-button--primary">Add images</span>
<input
accept="image/png,image/jpeg,image/webp"
className="signage-upload-input"
data-testid="signage-upload-input"
multiple
onChange={onFiles}
type="file"
/>
</label>
) : 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 : (
<div className="signage-uploads" data-testid="signage-uploads">
<ul className="ek-list">
{uploads.map((item) => (
<li className="ek-list__item signage-row" key={item.id}>
<span className="signage-row__name">{item.name}</span>
<Badge
tone={
item.status === "done"
? "success"
: item.status === "failed" || item.status === "rejected"
? "danger"
: "info"
}
>
{item.status}
</Badge>
{item.message === undefined ? undefined : (
<span className="signage-uploads__message">{item.message}</span>
)}
</li>
))}
</ul>
{finished.length === 0 ? undefined : (
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
)}
</div>
)}
{renderLoadable(
images.state,
(value) =>
value.images.length === 0 ? (
<EmptyState
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
title="No images yet"
/>
) : (
<ul className="signage-image-grid" data-testid="signage-image-grid">
{value.images.map((image) => {
const thumbnail = image.renditions.find(
(rendition) => rendition.kind === "thumbnail",
);
return (
<li className="signage-image" key={image.id}>
{thumbnail === undefined ? (
<div className="signage-image__missing">No preview</div>
) : (
<img
alt={image.displayName}
className="signage-image__thumbnail"
height={thumbnail.height}
src={thumbnail.url}
width={thumbnail.width}
/>
)}
<div className="signage-image__meta">
{renaming?.id === image.id ? (
<Field label="Name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setRenaming({ id: image.id, value: event.target.value })
}
value={renaming.value}
/>
)}
</Field>
) : (
<p className="signage-image__name">{image.displayName}</p>
)}
<p className="signage-image__detail">
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
</p>
{image.expiresAt === null ? undefined : (
<Badge className="signage-image__expiry" tone="warning">
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
</Badge>
)}
</div>
{canManage ? (
<div className="signage-image__actions">
{renaming?.id === image.id ? (
<>
<Button
disabled={rename.pending}
onClick={() =>
rename.run(() =>
api.send("PATCH", `images/${image.id}`, {
displayName: renaming.value,
}),
)
}
variant="primary"
>
Save
</Button>
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
</>
) : (
<Button
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
>
Rename
</Button>
)}
<Button
disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
variant="danger"
>
Delete
</Button>
</div>
) : undefined}
</li>
);
})}
</ul>
),
{ label: "Loading images…", onRetry: images.reload },
)}
<FailureNote failure={rename.failure ?? remove.failure} />
</Panel>
);
}