feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { type SignageApiFailure, SignageRequestError } from "./api.js";
/**
* One loading state, used everywhere, so every panel in the dashboard fails the same way.
*
* The states are the ones an operator can actually act on: still loading, loaded, or failed with a
* reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what
* empty means for its own data, because "no timelines yet" and "no displays yet" are different
* sentences.
*/
export type Loadable<Value> =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly value: Value }
| { readonly status: "failed"; readonly failure: SignageApiFailure };
export interface Resource<Value> {
readonly state: Loadable<Value>;
/** Re-reads without flashing the panel back to a spinner: the previous value stays on screen
* until the new one arrives, which is what makes a five-second refresh bearable to look at. */
reload(): void;
}
export function useResource<Value>(
load: () => Promise<Value>,
dependencies: readonly unknown[],
): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
const generation = useRef(0);
const loadRef = useRef(load);
loadRef.current = load;
const run = useCallback(() => {
generation.current += 1;
const token = generation.current;
void loadRef
.current()
.then((value) => {
// A response from a request the panel has already moved on from must never overwrite a
// newer one.
if (token === generation.current) {
setState({ status: "ready", value });
}
})
.catch((error: unknown) => {
if (token !== generation.current) {
return;
}
setState({
failure:
error instanceof SignageRequestError
? error.failure
: {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
},
status: "failed",
});
});
}, []);
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
// change, which is the whole reason this takes one.
useEffect(run, [run, ...dependencies]);
return { reload: run, state };
}
/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form
* can say why a save was refused. */
export interface Action {
readonly pending: boolean;
readonly failure: SignageApiFailure | undefined;
run(work: () => Promise<unknown>): void;
clearFailure(): void;
}
export function useAction(onSettled?: () => void): Action {
const [pending, setPending] = useState(false);
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
const settled = useRef(onSettled);
settled.current = onSettled;
const run = useCallback((work: () => Promise<unknown>) => {
setPending(true);
setFailure(undefined);
void work()
.then(() => {
setPending(false);
settled.current?.();
})
.catch((error: unknown) => {
setPending(false);
setFailure(
error instanceof SignageRequestError
? error.failure
: {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
},
);
});
}, []);
return { clearFailure: () => setFailure(undefined), failure, pending, run };
}
/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel
* inventing its own timer. Paused when the interval is zero. */
export function useTick(intervalMs: number): number {
const [tick, setTick] = useState(0);
useEffect(() => {
if (intervalMs <= 0) {
return;
}
const handle = setInterval(() => setTick((value) => value + 1), intervalMs);
return () => clearInterval(handle);
}, [intervalMs]);
return tick;
}