Files
festipod/src/app/router.tsx
T

196 lines
7.0 KiB
TypeScript

import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
// ============================================================================
// Route types
// ============================================================================
type Route =
| { page: 'welcome' }
| { page: 'home' }
| { page: 'events' }
| { page: 'create-event' }
| { page: 'event-detail'; eventId: string }
| { page: 'update-event'; eventId: string }
| { page: 'invite'; eventId: string }
| { page: 'participants'; eventId: string }
| { page: 'meeting-points'; eventId: string }
| { page: 'profile' }
| { page: 'edit-profile' }
| { page: 'friends' }
| { page: 'share-profile' }
| { page: 'connect' }
| { page: 'user-profile'; userId: string }
| { page: 'settings' }
| { page: 'login' };
export type { Route };
export interface RouteParams {
eventId?: string;
userId?: string;
}
// ============================================================================
// Path parsing & generation
// ============================================================================
/**
* Decode one path segment. Ids on the ActivityPods backend are absolute IRIs, so
* they are percent-encoded by `routeToPath`; a malformed escape (a raw IRI that
* was never encoded) is returned verbatim rather than throwing — the captured
* text IS the id in that case.
*/
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export function parsePath(pathname: string): Route {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/' || path === '') return { page: 'welcome' };
if (path === '/home') return { page: 'home' };
if (path === '/events') return { page: 'events' };
if (path === '/events/new') return { page: 'create-event' };
if (path === '/login') return { page: 'login' };
if (path === '/settings') return { page: 'settings' };
if (path === '/profile') return { page: 'profile' };
if (path === '/profile/edit') return { page: 'edit-profile' };
if (path === '/profile/friends') return { page: 'friends' };
if (path === '/profile/share') return { page: 'share-profile' };
if (path === '/profile/connect') return { page: 'connect' };
// /events/:id/... — the id is an absolute IRI, encoded by `routeToPath` so it
// holds no `/`; the sub-route is matched from the tail, and a raw (unencoded)
// IRI still parses as the whole remainder. An ENCODED id may itself contain a
// `/edit` segment without being read as a sub-route, which is why encoding is
// mandatory on every navigation.
const eventMatch = path.match(/^\/events\/(.+?)(?:\/(edit|invite|participants|meeting-points))?$/);
if (eventMatch) {
const eventId = decodeSegment(eventMatch[1]!);
const sub = eventMatch[2];
if (!sub) return { page: 'event-detail', eventId };
if (sub === 'edit') return { page: 'update-event', eventId };
if (sub === 'invite') return { page: 'invite', eventId };
if (sub === 'participants') return { page: 'participants', eventId };
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
}
// /users/:id — an absolute actor URI, encoded for the same reason.
const userMatch = path.match(/^\/users\/(.+)$/);
if (userMatch) {
return { page: 'user-profile', userId: decodeSegment(userMatch[1]!) };
}
return { page: 'welcome' };
}
export function routeToPath(route: Route): string {
switch (route.page) {
case 'welcome': return '/';
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'login': return '/login';
// Ids are absolute IRIs: they MUST be percent-encoded into a single path
// segment, or the `/` and `:` inside them are read as route structure and
// navigation falls through to the welcome page. `parsePath` decodes them.
case 'event-detail': return `/events/${encodeURIComponent(route.eventId)}`;
case 'update-event': return `/events/${encodeURIComponent(route.eventId)}/edit`;
case 'invite': return `/events/${encodeURIComponent(route.eventId)}/invite`;
case 'participants': return `/events/${encodeURIComponent(route.eventId)}/participants`;
case 'meeting-points': return `/events/${encodeURIComponent(route.eventId)}/meeting-points`;
case 'profile': return '/profile';
case 'edit-profile': return '/profile/edit';
case 'friends': return '/profile/friends';
case 'share-profile': return '/profile/share';
case 'connect': return '/profile/connect';
case 'user-profile': return `/users/${encodeURIComponent(route.userId)}`;
case 'settings': return '/settings';
}
}
/**
* Build a path to an event sub-route, encoding the id. Screens navigate by path
* rather than by `Route`, so this is the one place that keeps a raw IRI out of
* the URL structure.
*/
export function eventPath(eventId: string, sub?: 'edit' | 'invite' | 'participants' | 'meeting-points'): string {
const base = `/events/${encodeURIComponent(eventId)}`;
return sub ? `${base}/${sub}` : base;
}
/** Build a path to a user profile, encoding the actor URI. */
export function userPath(userId: string): string {
return `/users/${encodeURIComponent(userId)}`;
}
// ============================================================================
// Router context
// ============================================================================
interface RouterContextValue {
route: Route;
navigate: (path: string) => void;
goBack: () => void;
params: RouteParams;
}
const RouterContext = createContext<RouterContextValue | null>(null);
export function RouterProvider({ children }: { children: React.ReactNode }) {
const [route, setRoute] = useState<Route>(() => parsePath(window.location.pathname));
useEffect(() => {
const handlePopState = () => {
setRoute(parsePath(window.location.pathname));
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const navigate = useCallback((path: string) => {
window.history.pushState(null, '', path);
setRoute(parsePath(path));
}, []);
const goBack = useCallback(() => {
window.history.back();
}, []);
const params: RouteParams = {};
if ('eventId' in route) params.eventId = route.eventId;
if ('userId' in route) params.userId = route.userId;
return (
<RouterContext.Provider value={{ route, navigate, goBack, params }}>
{children}
</RouterContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
export function useRouter() {
const context = useContext(RouterContext);
if (!context) throw new Error('useRouter must be used within a RouterProvider');
return context;
}
export function useNavigate() {
return useRouter().navigate;
}
export function useGoBack() {
return useRouter().goBack;
}
export function useParams(): RouteParams {
return useRouter().params;
}