Files
Epic-The-Musical-Timeline/src/app/state.ts
T
2026-10-30 04:57:36 +01:00

96 lines
3.0 KiB
TypeScript

import { motifs, songById } from "../data/album";
export type Tab = "about" | "listen" | "paths" | "fan";
export interface AppState {
songId: string;
tab: Tab;
motif: string | null;
timeline: "open" | "closed";
ribbons: boolean;
read: boolean;
}
export const DEFAULT_STATE: AppState = {
songId: "the-horse-and-the-infant",
tab: "about",
motif: null,
timeline: "open",
ribbons: false,
read: false,
};
const TABS: Tab[] = ["about", "listen", "paths", "fan"];
/** Reads the app state from a query string; anything unknown falls back to the default. */
export function parseState(search: string): AppState {
const q = new URLSearchParams(search);
const song = q.get("song");
const tab = q.get("tab") as Tab | null;
const motif = q.get("motif");
return {
songId: song && songById(song) ? song : DEFAULT_STATE.songId,
tab: tab && TABS.includes(tab) ? tab : DEFAULT_STATE.tab,
motif: motif && motifs.some((m) => m.id === motif) ? motif : null,
timeline: q.get("timeline") === "closed" ? "closed" : "open",
ribbons: q.get("ribbons") === "1",
read: q.get("read") === "1",
};
}
/** The query string for a state, leaving out defaults ("" for the default state). */
export function stateToSearch(s: AppState): string {
const q = new URLSearchParams();
if (s.songId !== DEFAULT_STATE.songId) q.set("song", s.songId);
if (s.tab !== DEFAULT_STATE.tab) q.set("tab", s.tab);
if (s.motif) q.set("motif", s.motif);
if (s.timeline === "closed") q.set("timeline", "closed");
if (s.ribbons) q.set("ribbons", "1");
if (s.read) q.set("read", "1");
const str = q.toString();
return str ? `?${str}` : "";
}
export interface Store {
get(): AppState;
/** Song changes push a history entry; everything else replaces it. `push` overrides. */
set(patch: Partial<AppState>, opts?: { push?: boolean }): void;
subscribe(fn: (s: AppState, prev: AppState) => void): () => void;
}
/** A tiny state store kept in sync with the URL (unless `history: false`, e.g. in tests). */
export function createStore(initial: AppState, opts: { history?: boolean } = {}): Store {
const useHistory = opts.history ?? true;
let state = initial;
const subs = new Set<(s: AppState, prev: AppState) => void>();
const emit = (prev: AppState) => subs.forEach((fn) => fn(state, prev));
if (useHistory) {
addEventListener("popstate", () => {
const prev = state;
state = parseState(location.search);
emit(prev);
});
}
return {
get: () => state,
set(patch, setOpts = {}) {
const prev = state;
const next = { ...state, ...patch };
if (stateToSearch(next) === stateToSearch(prev)) return;
state = next;
if (useHistory) {
const url = location.pathname + stateToSearch(state);
const push = setOpts.push ?? next.songId !== prev.songId;
if (push) history.pushState(null, "", url);
else history.replaceState(null, "", url);
}
emit(prev);
},
subscribe(fn) {
subs.add(fn);
return () => subs.delete(fn);
},
};
}