diff --git a/src/app-state.ts b/src/app-state.ts index 8b0beb3..d3de814 100644 --- a/src/app-state.ts +++ b/src/app-state.ts @@ -60,10 +60,14 @@ export function goToToday(): void { setDate(todayIso()); } -export function shiftDate(days: number): void { - const d = new Date(`${state.date}T00:00:00Z`); +export function addDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00Z`); 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 { diff --git a/src/router.ts b/src/router.ts index 9d96434..b8c8046 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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'; // 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); } -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; if (!path.startsWith(BASE)) return {}; const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean); + const languages = parseLanguages(new URLSearchParams(window.location.search).get('lang')); return { date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined, hour: hour && isHourId(hour) ? hour : undefined, + languages, }; } -function urlFor(date: string, hour: HourId): string { - return `${BASE}${date}/${hour}`; +/** Exported for reuse by day-nav/bilingual-toggle so they can render real + * `` 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 = ''; function pushUrl(): void { - const { date, selectedHour } = getState(); - const url = urlFor(date, selectedHour); + const { date, selectedHour, languages } = getState(); + const url = urlFor(date, selectedHour, languages); if (url === lastUrl) return; lastUrl = url; window.history.pushState(null, '', url); } export function initRouter(): void { - const { date, hour } = parseLocation(); + const { date, hour, languages } = parseLocation(); if (date) setDate(date); if (hour) setSelectedHour(hour); + if (languages) setLanguages(languages); - const { date: currentDate, selectedHour } = getState(); - lastUrl = urlFor(currentDate, selectedHour); + const { date: currentDate, selectedHour, languages: currentLanguages } = getState(); + lastUrl = urlFor(currentDate, selectedHour, currentLanguages); window.history.replaceState(null, '', lastUrl); subscribe(() => pushUrl()); @@ -49,8 +67,9 @@ export function initRouter(): void { const parsed = parseLocation(); // Set lastUrl first so the notify() inside setDate/setSelectedHour below // 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.hour) setSelectedHour(parsed.hour); + if (parsed.languages) setLanguages(parsed.languages); }); } diff --git a/src/ui/bilingual-toggle.ts b/src/ui/bilingual-toggle.ts index 548bc1d..cc1fd54 100644 --- a/src/ui/bilingual-toggle.ts +++ b/src/ui/bilingual-toggle.ts @@ -1,4 +1,5 @@ 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 // 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 { - const { languages } = getState(); + const { date, selectedHour, languages } = getState(); const current = languages.join('+'); container.innerHTML = `
${OPTIONS.map((opt) => { const isActive = opt.languages.join('+') === current; - return ``; + const url = urlFor(date, selectedHour, opt.languages); + return `${opt.label}`; }).join('')}
`; - container.querySelectorAll('[data-langs]').forEach((btn) => { - btn.addEventListener('click', () => { - const langs = btn.dataset.langs!.split(',') as AppState['languages']; + container.querySelectorAll('[data-langs]').forEach((link) => { + link.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.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + const langs = link.dataset.langs!.split(',') as AppState['languages']; setLanguages(langs); }); }); diff --git a/src/ui/day-nav.ts b/src/ui/day-nav.ts index 4076dd7..0f454c6 100644 --- a/src/ui/day-nav.ts +++ b/src/ui/day-nav.ts @@ -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 { getDayLabel } from '../calendar/day-label'; +import { urlFor } from '../router'; import { formatDateLong, renderBilingual } from './format'; /** 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` * rather than through any particular hour's `resolveOrdo`. */ export function renderDayNav(container: HTMLElement): void { - const { date, languages } = getState(); + const { date, selectedHour, languages } = getState(); const day = resolveDay(date); const dateLabel = formatDateLong(date); 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.querySelector('[data-action="prev"]')?.addEventListener('click', () => shiftDate(-1)); - container.querySelector('[data-action="next"]')?.addEventListener('click', () => shiftDate(1)); - container.querySelector('[data-action="today"]')?.addEventListener('click', () => goToToday()); + + const bindNav = (action: string, mutate: () => void): void => { + container.querySelector(`[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()); } diff --git a/src/ui/hour-list.ts b/src/ui/hour-list.ts index 34c0581..7a84b37 100644 --- a/src/ui/hour-list.ts +++ b/src/ui/hour-list.ts @@ -1,13 +1,21 @@ import { getState, setSelectedHour } from '../app-state'; -import { HOUR_IDS, resolveOrdo, type HourId } from '../hours'; +import { resolveOrdo, type HourId } from '../hours'; import { hourLabel } from './format'; 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 { const { date, selectedHour } = getState(); - const items = HOUR_IDS.map((hourId) => { + const renderItem = (hourId: HourId): string => { const ordo = resolveOrdo(hourId, date); const isSelected = hourId === selectedHour; const isReady = !ordo.notImplemented; @@ -24,9 +32,12 @@ export function renderHourList(container: HTMLElement): void { `; - }).join(''); + }; - container.innerHTML = `
    ${items}
`; + const separator = '
  •  
  • '; + const rows = HOUR_ROWS.map((row) => `
      ${row.map(renderItem).join(separator)}
    `).join(''); + + container.innerHTML = rows; container.querySelectorAll('[data-hour]').forEach((link) => { link.addEventListener('click', (event) => { diff --git a/src/ui/styles.css b/src/ui/styles.css index f8802cf..bbd39f0 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -198,9 +198,16 @@ button:focus-visible { padding: var(--space-3) var(--space-2); } +.hour-list-nav { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + .hour-list { display: flex; flex-wrap: wrap; + gap: var(--space-2); list-style: none; margin: 0; padding: 0; @@ -239,6 +246,12 @@ button:focus-visible { opacity: 0.85; } +.hour-sep { + display: flex; + align-items: center; + color: var(--color-border); +} + .hour-status { font-size: 0.7rem; text-transform: uppercase;