Make day-nav/language-toggle real links, group hours into rows
Deploy / deploy (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m19s
Day navigation (prev/today/next) and the language toggle were plain buttons wired to click handlers only; switched them to real <a href> elements (built via router's new exported urlFor) so right-click, middle-click, and open-in-new-tab work, with a click handler that still SPA-routes on a plain left-click and lets modified clicks fall through to normal browser navigation. Language selection is now reflected in the URL as a ?lang= query param (parsed/pushed by router.ts, omitted for the default single- English case to keep that URL clean), so a shared link preserves the viewer's chosen language(s). hour-list.ts now groups the 8 hours into three traditional rows (night office, day hours, evening) instead of one flat wrapped list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc
This commit is contained in:
+7
-3
@@ -60,10 +60,14 @@ export function goToToday(): void {
|
|||||||
setDate(todayIso());
|
setDate(todayIso());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shiftDate(days: number): void {
|
export function addDays(date: string, days: number): string {
|
||||||
const d = new Date(`${state.date}T00:00:00Z`);
|
const d = new Date(`${date}T00:00:00Z`);
|
||||||
d.setUTCDate(d.getUTCDate() + days);
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
setDate(d.toISOString().slice(0, 10));
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftDate(days: number): void {
|
||||||
|
setDate(addDays(state.date, days));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSelectedHour(hourId: HourId): void {
|
export function setSelectedHour(hourId: HourId): void {
|
||||||
|
|||||||
+29
-10
@@ -1,4 +1,4 @@
|
|||||||
import { getState, setDate, setSelectedHour, subscribe } from './app-state';
|
import { getState, setDate, setSelectedHour, setLanguages, subscribe, type AppState } from './app-state';
|
||||||
import { HOUR_IDS, type HourId } from './hours/types';
|
import { HOUR_IDS, type HourId } from './hours/types';
|
||||||
|
|
||||||
// Real paths (reground.org/vu/2026-08-09/prime), not hash routing — the
|
// Real paths (reground.org/vu/2026-08-09/prime), not hash routing — the
|
||||||
@@ -10,37 +10,55 @@ function isHourId(value: string): value is HourId {
|
|||||||
return (HOUR_IDS as readonly string[]).includes(value);
|
return (HOUR_IDS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLocation(): { date?: string; hour?: HourId } {
|
function parseLanguages(value: string | null): AppState['languages'] | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const langs = value.split(',').filter(Boolean);
|
||||||
|
if (langs.length === 1) return [langs[0]!];
|
||||||
|
if (langs.length === 2) return [langs[0]!, langs[1]!];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocation(): { date?: string; hour?: HourId; languages?: AppState['languages'] } {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (!path.startsWith(BASE)) return {};
|
if (!path.startsWith(BASE)) return {};
|
||||||
const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean);
|
const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean);
|
||||||
|
const languages = parseLanguages(new URLSearchParams(window.location.search).get('lang'));
|
||||||
return {
|
return {
|
||||||
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined,
|
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined,
|
||||||
hour: hour && isHourId(hour) ? hour : undefined,
|
hour: hour && isHourId(hour) ? hour : undefined,
|
||||||
|
languages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlFor(date: string, hour: HourId): string {
|
/** Exported for reuse by day-nav/bilingual-toggle so they can render real
|
||||||
return `${BASE}${date}/${hour}`;
|
* `<a href>` links (right-click / open-in-new-tab / copy-link) instead of
|
||||||
|
* reimplementing this path-building logic. */
|
||||||
|
export function urlFor(date: string, hour: HourId, languages: AppState['languages']): string {
|
||||||
|
const base = `${BASE}${date}/${hour}`;
|
||||||
|
// Omit the query string for the default single-English case to keep the
|
||||||
|
// common-case URL clean.
|
||||||
|
if (languages.length === 1 && languages[0] === 'en') return base;
|
||||||
|
return `${base}?lang=${languages.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastUrl = '';
|
let lastUrl = '';
|
||||||
|
|
||||||
function pushUrl(): void {
|
function pushUrl(): void {
|
||||||
const { date, selectedHour } = getState();
|
const { date, selectedHour, languages } = getState();
|
||||||
const url = urlFor(date, selectedHour);
|
const url = urlFor(date, selectedHour, languages);
|
||||||
if (url === lastUrl) return;
|
if (url === lastUrl) return;
|
||||||
lastUrl = url;
|
lastUrl = url;
|
||||||
window.history.pushState(null, '', url);
|
window.history.pushState(null, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRouter(): void {
|
export function initRouter(): void {
|
||||||
const { date, hour } = parseLocation();
|
const { date, hour, languages } = parseLocation();
|
||||||
if (date) setDate(date);
|
if (date) setDate(date);
|
||||||
if (hour) setSelectedHour(hour);
|
if (hour) setSelectedHour(hour);
|
||||||
|
if (languages) setLanguages(languages);
|
||||||
|
|
||||||
const { date: currentDate, selectedHour } = getState();
|
const { date: currentDate, selectedHour, languages: currentLanguages } = getState();
|
||||||
lastUrl = urlFor(currentDate, selectedHour);
|
lastUrl = urlFor(currentDate, selectedHour, currentLanguages);
|
||||||
window.history.replaceState(null, '', lastUrl);
|
window.history.replaceState(null, '', lastUrl);
|
||||||
|
|
||||||
subscribe(() => pushUrl());
|
subscribe(() => pushUrl());
|
||||||
@@ -49,8 +67,9 @@ export function initRouter(): void {
|
|||||||
const parsed = parseLocation();
|
const parsed = parseLocation();
|
||||||
// Set lastUrl first so the notify() inside setDate/setSelectedHour below
|
// Set lastUrl first so the notify() inside setDate/setSelectedHour below
|
||||||
// doesn't turn this back-navigation into a new forward pushState.
|
// doesn't turn this back-navigation into a new forward pushState.
|
||||||
lastUrl = window.location.pathname;
|
lastUrl = window.location.pathname + window.location.search;
|
||||||
if (parsed.date) setDate(parsed.date);
|
if (parsed.date) setDate(parsed.date);
|
||||||
if (parsed.hour) setSelectedHour(parsed.hour);
|
if (parsed.hour) setSelectedHour(parsed.hour);
|
||||||
|
if (parsed.languages) setLanguages(parsed.languages);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getState, setLanguages, type AppState } from '../app-state';
|
import { getState, setLanguages, type AppState } from '../app-state';
|
||||||
|
import { urlFor } from '../router';
|
||||||
|
|
||||||
// Hardcoded to en/la since those are the only languages with any data so
|
// Hardcoded to en/la since those are the only languages with any data so
|
||||||
// far. The two-language ceiling (never three+ columns) is the real
|
// far. The two-language ceiling (never three+ columns) is the real
|
||||||
@@ -11,21 +12,28 @@ const OPTIONS: { label: string; languages: AppState['languages'] }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function renderLanguageToggle(container: HTMLElement): void {
|
export function renderLanguageToggle(container: HTMLElement): void {
|
||||||
const { languages } = getState();
|
const { date, selectedHour, languages } = getState();
|
||||||
const current = languages.join('+');
|
const current = languages.join('+');
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="language-toggle" role="group" aria-label="Display language">
|
<div class="language-toggle" role="group" aria-label="Display language">
|
||||||
${OPTIONS.map((opt) => {
|
${OPTIONS.map((opt) => {
|
||||||
const isActive = opt.languages.join('+') === current;
|
const isActive = opt.languages.join('+') === current;
|
||||||
return `<button type="button" class="lang-btn${isActive ? ' is-active' : ''}" data-langs="${opt.languages.join(',')}">${opt.label}</button>`;
|
const url = urlFor(date, selectedHour, opt.languages);
|
||||||
|
return `<a href="${url}" class="lang-btn${isActive ? ' is-active' : ''}" data-langs="${opt.languages.join(',')}">${opt.label}</a>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.querySelectorAll<HTMLButtonElement>('[data-langs]').forEach((btn) => {
|
container.querySelectorAll<HTMLAnchorElement>('[data-langs]').forEach((link) => {
|
||||||
btn.addEventListener('click', () => {
|
link.addEventListener('click', (event) => {
|
||||||
const langs = btn.dataset.langs!.split(',') as AppState['languages'];
|
// Let modifier/middle clicks fall through to normal browser navigation
|
||||||
|
// (open in new tab, etc.) — only plain left-clicks are SPA-routed.
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const langs = link.dataset.langs!.split(',') as AppState['languages'];
|
||||||
setLanguages(langs);
|
setLanguages(langs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-8
@@ -1,6 +1,7 @@
|
|||||||
import { getState, shiftDate, goToToday } from '../app-state';
|
import { getState, shiftDate, goToToday, addDays, todayIso } from '../app-state';
|
||||||
import { resolveDay } from '../calendar';
|
import { resolveDay } from '../calendar';
|
||||||
import { getDayLabel } from '../calendar/day-label';
|
import { getDayLabel } from '../calendar/day-label';
|
||||||
|
import { urlFor } from '../router';
|
||||||
import { formatDateLong, renderBilingual } from './format';
|
import { formatDateLong, renderBilingual } from './format';
|
||||||
|
|
||||||
/** Renders navigation plus the merged "when" header: the civil date/
|
/** Renders navigation plus the merged "when" header: the civil date/
|
||||||
@@ -11,22 +12,37 @@ import { formatDateLong, renderBilingual } from './format';
|
|||||||
* hour on a given date), so it's resolved directly here via `resolveDay`
|
* hour on a given date), so it's resolved directly here via `resolveDay`
|
||||||
* rather than through any particular hour's `resolveOrdo`. */
|
* rather than through any particular hour's `resolveOrdo`. */
|
||||||
export function renderDayNav(container: HTMLElement): void {
|
export function renderDayNav(container: HTMLElement): void {
|
||||||
const { date, languages } = getState();
|
const { date, selectedHour, languages } = getState();
|
||||||
const day = resolveDay(date);
|
const day = resolveDay(date);
|
||||||
const dateLabel = formatDateLong(date);
|
const dateLabel = formatDateLong(date);
|
||||||
const dayLabel = getDayLabel(day);
|
const dayLabel = getDayLabel(day);
|
||||||
|
const prevUrl = urlFor(addDays(date, -1), selectedHour, languages);
|
||||||
|
const nextUrl = urlFor(addDays(date, 1), selectedHour, languages);
|
||||||
|
const todayUrl = urlFor(todayIso(), selectedHour, languages);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<nav class="day-nav" aria-label="Day navigation">
|
<nav class="day-nav" aria-label="Day navigation">
|
||||||
<button type="button" class="day-nav-btn" data-action="prev" aria-label="Previous day">←</button>
|
<a href="${prevUrl}" class="day-nav-btn" data-action="prev" aria-label="Previous day">←</a>
|
||||||
<button type="button" class="day-nav-btn day-nav-today" data-action="today">Today</button>
|
<a href="${todayUrl}" class="day-nav-btn day-nav-today" data-action="today">Today</a>
|
||||||
<div class="day-nav-header">
|
<div class="day-nav-header">
|
||||||
${renderBilingual(dateLabel, languages, 'day-nav-date')}
|
${renderBilingual(dateLabel, languages, 'day-nav-date')}
|
||||||
${renderBilingual(dayLabel, languages, 'day-nav-label')}
|
${renderBilingual(dayLabel, languages, 'day-nav-label')}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="day-nav-btn" data-action="next" aria-label="Next day">→</button>
|
<a href="${nextUrl}" class="day-nav-btn" data-action="next" aria-label="Next day">→</a>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
container.querySelector('[data-action="prev"]')?.addEventListener('click', () => shiftDate(-1));
|
|
||||||
container.querySelector('[data-action="next"]')?.addEventListener('click', () => shiftDate(1));
|
const bindNav = (action: string, mutate: () => void): void => {
|
||||||
container.querySelector('[data-action="today"]')?.addEventListener('click', () => goToToday());
|
container.querySelector<HTMLAnchorElement>(`[data-action="${action}"]`)?.addEventListener('click', (event) => {
|
||||||
|
// Let modifier/middle clicks fall through to normal browser navigation
|
||||||
|
// (open in new tab, etc.) — only plain left-clicks are SPA-routed.
|
||||||
|
if (event.defaultPrevented || (event as MouseEvent).button !== 0 || (event as MouseEvent).metaKey || (event as MouseEvent).ctrlKey || (event as MouseEvent).shiftKey || (event as MouseEvent).altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
mutate();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bindNav('prev', () => shiftDate(-1));
|
||||||
|
bindNav('next', () => shiftDate(1));
|
||||||
|
bindNav('today', () => goToToday());
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-4
@@ -1,13 +1,21 @@
|
|||||||
import { getState, setSelectedHour } from '../app-state';
|
import { getState, setSelectedHour } from '../app-state';
|
||||||
import { HOUR_IDS, resolveOrdo, type HourId } from '../hours';
|
import { resolveOrdo, type HourId } from '../hours';
|
||||||
import { hourLabel } from './format';
|
import { hourLabel } from './format';
|
||||||
|
|
||||||
const BASE = import.meta.env.BASE_URL;
|
const BASE = import.meta.env.BASE_URL;
|
||||||
|
|
||||||
|
// Traditional grouping into rows, each starting a "hinge" hour: night
|
||||||
|
// office, day hours, evening — rather than one flat wrapped list.
|
||||||
|
const HOUR_ROWS: readonly HourId[][] = [
|
||||||
|
['matins', 'lauds'],
|
||||||
|
['prime', 'terce', 'sext', 'none'],
|
||||||
|
['vespers', 'compline'],
|
||||||
|
];
|
||||||
|
|
||||||
export function renderHourList(container: HTMLElement): void {
|
export function renderHourList(container: HTMLElement): void {
|
||||||
const { date, selectedHour } = getState();
|
const { date, selectedHour } = getState();
|
||||||
|
|
||||||
const items = HOUR_IDS.map((hourId) => {
|
const renderItem = (hourId: HourId): string => {
|
||||||
const ordo = resolveOrdo(hourId, date);
|
const ordo = resolveOrdo(hourId, date);
|
||||||
const isSelected = hourId === selectedHour;
|
const isSelected = hourId === selectedHour;
|
||||||
const isReady = !ordo.notImplemented;
|
const isReady = !ordo.notImplemented;
|
||||||
@@ -24,9 +32,12 @@ export function renderHourList(container: HTMLElement): void {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
};
|
||||||
|
|
||||||
container.innerHTML = `<ul class="hour-list">${items}</ul>`;
|
const separator = '<li class="hour-sep"> </li>';
|
||||||
|
const rows = HOUR_ROWS.map((row) => `<ul class="hour-list">${row.map(renderItem).join(separator)}</ul>`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = rows;
|
||||||
|
|
||||||
container.querySelectorAll<HTMLAnchorElement>('[data-hour]').forEach((link) => {
|
container.querySelectorAll<HTMLAnchorElement>('[data-hour]').forEach((link) => {
|
||||||
link.addEventListener('click', (event) => {
|
link.addEventListener('click', (event) => {
|
||||||
|
|||||||
@@ -198,9 +198,16 @@ button:focus-visible {
|
|||||||
padding: var(--space-3) var(--space-2);
|
padding: var(--space-3) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hour-list-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.hour-list {
|
.hour-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -239,6 +246,12 @@ button:focus-visible {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hour-sep {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.hour-status {
|
.hour-status {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
Reference in New Issue
Block a user