From a7526e12ec9770bb475039be3fd5f9e63288edb4 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 12:35:00 +0000 Subject: [PATCH] feat: add mobile-first daisyui server-rendered UI components --- src/views/pages.js | 93 ++++++++++++++++++++++++++++++++++++++++++++++ test/views.test.js | 33 ++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/views/pages.js create mode 100644 test/views.test.js diff --git a/src/views/pages.js b/src/views/pages.js new file mode 100644 index 0000000..20d31d0 --- /dev/null +++ b/src/views/pages.js @@ -0,0 +1,93 @@ +"use strict"; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function layout({ title, content }) { + return ` + + + + + ${escapeHtml(title)} + + + +
+ ${content} +
+ +`; +} + +function renderHomePage({ authenticated, userId, balance, jobs }) { + const authBlock = authenticated + ? `
Signed in as ${escapeHtml(userId)}
` + : `
Not authenticated. Add header x-user-id in API calls.
`; + + const jobsMarkup = jobs.length === 0 + ? `

No jobs yet.

` + : ``; + + return layout({ + title: "X Article to Audio", + content: ` +
+
+

X Article to Audio

+

Webhook-first mention bot MVP with permanent paid unlocks.

+ ${authBlock} +
+
+
Wallet Credits
+
${balance}
+
+
+
+
+
+
+

Recent Audiobooks

+ ${jobsMarkup} +
+
+ `, + }); +} + +function renderAudioPage({ audio, accessDecision }) { + if (!audio) { + return layout({ + title: "Audio not found", + content: `
Audio not found.
`, + }); + } + + const content = accessDecision.allowed + ? `
Access granted. Stream key: ${escapeHtml(audio.storageKey)}
+

${escapeHtml(audio.articleTitle)}

Duration: ${audio.durationSec}s

` + : `
${ + accessDecision.reason === "auth_required" + ? "Sign in required before playback." + : `Unlock required: ${accessDecision.creditsRequired} credits.` + }
`; + + return layout({ + title: "Audiobook", + content, + }); +} + +module.exports = { + layout, + renderHomePage, + renderAudioPage, +}; diff --git a/test/views.test.js b/test/views.test.js new file mode 100644 index 0000000..a9ac33b --- /dev/null +++ b/test/views.test.js @@ -0,0 +1,33 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { layout, renderHomePage, renderAudioPage } = require("../src/views/pages"); + +test("layout includes daisyui stylesheet and mobile-first wrapper", () => { + const html = layout({ title: "t", content: "x" }); + assert.match(html, /daisyui@5/); + assert.match(html, /max-w-md mx-auto p-4/); +}); + +test("home page renders jobs list and wallet credits", () => { + const html = renderHomePage({ + authenticated: true, + userId: "u1", + balance: 7, + jobs: [{ assetId: "a1", status: "completed", article: { title: "Hello" } }], + }); + + assert.match(html, /Wallet Credits/); + assert.match(html, /7/); + assert.match(html, /Hello/); +}); + +test("audio page asks auth/payment when access is denied", () => { + const html = renderAudioPage({ + audio: { id: "a1", storageKey: "audio/a1.mp3", articleTitle: "A", durationSec: 30 }, + accessDecision: { allowed: false, reason: "payment_required", creditsRequired: 3 }, + }); + + assert.match(html, /Unlock required: 3 credits/); +});