Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
233 lines
8.9 KiB
TypeScript
233 lines
8.9 KiB
TypeScript
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<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>
|
||
)}
|
||
<ConfirmButton
|
||
confirmLabel="Delete this image"
|
||
disabled={remove.pending}
|
||
label="Delete"
|
||
onConfirm={() =>
|
||
remove.run(() => api.send("DELETE", `images/${image.id}`))
|
||
}
|
||
testId={`signage-image-delete-${image.id}`}
|
||
/>
|
||
</div>
|
||
) : undefined}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
),
|
||
{ label: "Loading images…", onRetry: images.reload },
|
||
)}
|
||
<FailureNote failure={rename.failure ?? remove.failure} />
|
||
</Panel>
|
||
);
|
||
}
|