apps/web: Next.js 16 (App Router), Tailwind 4 and shadcn/ui, light and dark from the system. Pages render on the server and call the API with Eden Treaty (typed by @t5de/api), forwarding the visitor's cookie; forms use server actions; sign-up, sign-in and sign-out use Better Auth's React client. /api is rewritten to the API so its cookies are first-party. Who's online follows the API's event stream (Next's gzip is off: it held the stream back). Standalone output with a Dockerfile; deploys to Vercel as is. Co-Authored-By: Claude Opus 5.5 <[email protected]>
76 lines
2.6 KiB
TypeScript
76 lines
2.6 KiB
TypeScript
"use client";
|
|
// Times in the visitor's own time zone and language. The server doesn't know either, so it renders times in UTC and
|
|
// the browser replaces them once the page is running.
|
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
|
|
const noSubscription = () => () => {};
|
|
|
|
/** False on the server and while hydrating, true after. */
|
|
function useInBrowser(): boolean {
|
|
return useSyncExternalStore(
|
|
noSubscription,
|
|
() => true,
|
|
() => false,
|
|
);
|
|
}
|
|
|
|
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
["year", 365 * 24 * 3600],
|
|
["month", 30 * 24 * 3600],
|
|
["week", 7 * 24 * 3600],
|
|
["day", 24 * 3600],
|
|
["hour", 3600],
|
|
["minute", 60],
|
|
];
|
|
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
|
|
function ago(iso: string, now: number): string {
|
|
const seconds = (new Date(iso).getTime() - now) / 1000;
|
|
for (const [unit, size] of units) {
|
|
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
|
|
}
|
|
return "just now";
|
|
}
|
|
|
|
/** "5 minutes ago", kept up to date. */
|
|
export function TimeAgo({ iso }: { iso: string }) {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
const local = useInBrowser();
|
|
return (
|
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
|
{local ? ago(iso, now) : format(iso, { dateStyle: "medium" }, false)}
|
|
</time>
|
|
);
|
|
}
|
|
|
|
function format(iso: string, options: Intl.DateTimeFormatOptions, local: boolean): string {
|
|
return new Date(iso).toLocaleString(local ? undefined : "en", local ? options : { ...options, timeZone: "UTC" });
|
|
}
|
|
|
|
/** A date (and time). */
|
|
export function DateTime({ iso, dateOnly = false }: { iso: string; dateOnly?: boolean }) {
|
|
const local = useInBrowser();
|
|
const options: Intl.DateTimeFormatOptions = dateOnly
|
|
? { dateStyle: "medium" }
|
|
: { dateStyle: "medium", timeStyle: "short" };
|
|
return <time dateTime={iso}>{format(iso, options, local)}</time>;
|
|
}
|
|
|
|
/** A time of day ("3:04 PM"), with the date too when it isn't today. */
|
|
export function Since({ iso }: { iso: string }) {
|
|
const local = useInBrowser();
|
|
const today = local && new Date(iso).toDateString() === new Date().toDateString();
|
|
const options: Intl.DateTimeFormatOptions = today
|
|
? { hour: "numeric", minute: "2-digit" }
|
|
: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
|
|
return (
|
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
|
{format(iso, options, local)}
|
|
</time>
|
|
);
|
|
}
|