Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
import { calendarById, calendarBySlug, DEFAULT_VISIBLE_CALENDAR_IDS } from "@/lib/calendars";
|
|
import { RANGES, SORTS, type Range, type Sort } from "@/lib/filter";
|
|
|
|
/**
|
|
* The admin list's view state, kept in the query string so a link reproduces
|
|
* a view: `?cal=public,choir&range=past&sort=start_desc&q=probe`.
|
|
*
|
|
* Defaults are left out of the URL, so the plain `/admin` link means "the
|
|
* default view". Anything unrecognised falls back to its default instead of
|
|
* failing - these URLs get pasted into chats and bookmarked.
|
|
*
|
|
* `cal=` with an empty value is kept as "no calendars selected"; only a
|
|
* missing `cal` means the default selection.
|
|
*
|
|
* The default sort depends on the range: looking back ("Vergangen") starts
|
|
* with the most recent event, everything else with the earliest. A sort that
|
|
* matches its range's default stays out of the URL, so switching ranges
|
|
* carries the right default along; a sort someone picked is kept.
|
|
*/
|
|
|
|
export interface ListState {
|
|
calendarIds: number[];
|
|
range: Range;
|
|
sort: Sort;
|
|
query: string;
|
|
}
|
|
|
|
export const DEFAULT_LIST_STATE: ListState = {
|
|
calendarIds: [...DEFAULT_VISIBLE_CALENDAR_IDS],
|
|
range: "future",
|
|
sort: "start_asc",
|
|
query: "",
|
|
};
|
|
|
|
export const defaultSortFor = (range: Range): Sort => (range === "past" ? "start_desc" : "start_asc");
|
|
|
|
/**
|
|
* The state after switching to `range`: a sort that was only the old range's
|
|
* default becomes the new range's default; one that someone picked stays.
|
|
*/
|
|
export const withRange = (state: ListState, range: Range): ListState => ({
|
|
...state,
|
|
range,
|
|
sort: state.sort === defaultSortFor(state.range) ? defaultSortFor(range) : state.sort,
|
|
});
|
|
|
|
const sameIds = (a: readonly number[], b: readonly number[]): boolean => {
|
|
const sortedB = [...b].sort();
|
|
return a.length === b.length && [...a].sort().every((id, i) => id === sortedB[i]);
|
|
};
|
|
|
|
export const parseListState = (params: URLSearchParams): ListState => {
|
|
const cal = params.get("cal");
|
|
const calendarIds =
|
|
cal === null
|
|
? [...DEFAULT_LIST_STATE.calendarIds]
|
|
: [
|
|
...new Set(
|
|
cal
|
|
.split(",")
|
|
.map((slug) => calendarBySlug(slug.trim())?.id)
|
|
.filter((id): id is number => id !== undefined),
|
|
),
|
|
];
|
|
|
|
const rawRange = params.get("range");
|
|
const rawSort = params.get("sort");
|
|
const range = RANGES.includes(rawRange as Range) ? (rawRange as Range) : DEFAULT_LIST_STATE.range;
|
|
|
|
return {
|
|
calendarIds,
|
|
range,
|
|
sort: SORTS.includes(rawSort as Sort) ? (rawSort as Sort) : defaultSortFor(range),
|
|
query: params.get("q") ?? "",
|
|
};
|
|
};
|
|
|
|
export const serializeListState = (state: ListState): URLSearchParams => {
|
|
const params = new URLSearchParams();
|
|
if (!sameIds(state.calendarIds, DEFAULT_LIST_STATE.calendarIds)) {
|
|
params.set(
|
|
"cal",
|
|
state.calendarIds
|
|
.map((id) => calendarById(id)?.slug)
|
|
.filter(Boolean)
|
|
.join(","),
|
|
);
|
|
}
|
|
if (state.range !== DEFAULT_LIST_STATE.range) {
|
|
params.set("range", state.range);
|
|
}
|
|
if (state.sort !== defaultSortFor(state.range)) {
|
|
params.set("sort", state.sort);
|
|
}
|
|
if (state.query.trim() !== "") {
|
|
params.set("q", state.query);
|
|
}
|
|
return params;
|
|
};
|
|
|
|
/**
|
|
* The query string for the address bar, without the leading "?". Commas stay
|
|
* literal (`cal=public,choir`) because these URLs are meant to be read and
|
|
* shared; URLSearchParams would write `%2C`. A comma inside the search text
|
|
* is escaped by hand first, so it cannot be mistaken for a separator - and
|
|
* the parser only splits `cal` anyway.
|
|
*/
|
|
export const toQueryString = (state: ListState): string =>
|
|
serializeListState(state)
|
|
.toString()
|
|
.replace(/(^|&)cal=([^&]*)/, (_, lead: string, value: string) => `${lead}cal=${value.replace(/%2C/gi, ",")}`);
|