Close the web-feasible 100% gaps: REST write/token API, tickets, draw-badge, /me, sanitisation, dusk, radio SSE

Final parity push (web-tier only):
- REST API write + token auth: POST /api/tokens (issue a personal_access_token
  for the session user), Bearer auth via src/lib/api-auth.ts, POST
  /api/articles/[slug]/comment, GET/DELETE /api/me/tokens, full tickets API
  (/api/tickets +[id] +[id]/reply), radio current-dj/points/points-leaderboard/
  embed-config + POST shouts, and a real-time /api/radio/stream (SSE). 31 public
  API routes total.
- Pages: /draw-badge (buy a custom profile badge → credits + RCON), /me
  dashboard (stats + online friends + referral claim). Wired into the nav.
- HTML sanitisation (sanitize-html) — the HTMLPurifier equivalent — applied to
  writeable boxes + article bodies before dangerouslySetInnerHTML.
- "Dusk" dark theme preset + a default-dark site option honoured by the
  no-flash boot script.

Verified live (prod, amx_test): token issue → Bearer endpoint 200, no-token
401; /api/me/tokens lists it; current-dj/leaderboard JSON; /me + /draw-badge
200; reverted the test user + tokens. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-29 18:15:01 +02:00
1 parent 8cedf5614e
commit f7b3845131
30 files changed
+1734 -10

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
import { fetchListeners, fetchNowPlaying } from "@/lib/services/radio";
export const dynamic = "force-dynamic";
/**
* Server-Sent Events stream of live radio state (AtomCMS's radio SSE endpoint).
* Pushes { nowPlaying, listeners } every ~10s so players/widgets get real-time
* updates without polling. Closes cleanly when the client disconnects.
*/
export async function GET(req: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let closed = false;
const send = async () => {
if (closed) return;
const [nowPlaying, listeners] = await Promise.all([
fetchNowPlaying().catch(() => null),
fetchListeners().catch(() => null),
]);
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ nowPlaying, listeners })}\n\n`));
} catch {
closed = true;
}
};
// Initial event immediately, then on an interval.
await send();
const interval = setInterval(() => void send(), 10_000);
// SSE comment as a keep-alive ping between data events.
const ping = setInterval(() => {
if (!closed) {
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
closed = true;
}
}
}, 25_000);
const stop = () => {
closed = true;
clearInterval(interval);
clearInterval(ping);
try {
controller.close();
} catch {
/* already closed */
}
};
req.signal.addEventListener("abort", stop);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
"access-control-allow-origin": "*",
},
});
}