96 lines
3.0 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|