feat: add mobile-first daisyui server-rendered UI components

This commit is contained in:
Codex
2026-02-18 12:35:00 +00:00
parent 53e0daecaf
commit a7526e12ec
2 changed files with 126 additions and 0 deletions

93
src/views/pages.js Normal file
View File

@@ -0,0 +1,93 @@
"use strict";
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function layout({ title, content }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/dist/full.css" rel="stylesheet" type="text/css" />
</head>
<body class="min-h-screen bg-base-200">
<main class="max-w-md mx-auto p-4 space-y-4">
${content}
</main>
</body>
</html>`;
}
function renderHomePage({ authenticated, userId, balance, jobs }) {
const authBlock = authenticated
? `<div class="alert alert-success text-sm">Signed in as <strong>${escapeHtml(userId)}</strong></div>`
: `<div class="alert alert-warning text-sm">Not authenticated. Add header <code>x-user-id</code> in API calls.</div>`;
const jobsMarkup = jobs.length === 0
? `<p class="text-sm opacity-70">No jobs yet.</p>`
: `<ul class="menu bg-base-100 rounded-box border border-base-300">${jobs
.map((job) => `<li><a href="/audio/${job.assetId}">${escapeHtml(job.article.title)} (${job.status})</a></li>`)
.join("")}</ul>`;
return layout({
title: "X Article to Audio",
content: `
<section class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4">
<h1 class="card-title text-lg">X Article to Audio</h1>
<p class="text-sm opacity-80">Webhook-first mention bot MVP with permanent paid unlocks.</p>
${authBlock}
<div class="stats stats-vertical lg:stats-horizontal shadow mt-2">
<div class="stat py-3">
<div class="stat-title">Wallet Credits</div>
<div class="stat-value text-2xl">${balance}</div>
</div>
</div>
</div>
</section>
<section class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4">
<h2 class="font-semibold">Recent Audiobooks</h2>
${jobsMarkup}
</div>
</section>
`,
});
}
function renderAudioPage({ audio, accessDecision }) {
if (!audio) {
return layout({
title: "Audio not found",
content: `<div class="alert alert-error">Audio not found.</div>`,
});
}
const content = accessDecision.allowed
? `<div class="alert alert-success">Access granted. Stream key: <code>${escapeHtml(audio.storageKey)}</code></div>
<div class="card bg-base-100 border border-base-300"><div class="card-body p-4"><h2 class="font-semibold">${escapeHtml(audio.articleTitle)}</h2><p class="text-sm">Duration: ${audio.durationSec}s</p></div></div>`
: `<div class="alert alert-warning">${
accessDecision.reason === "auth_required"
? "Sign in required before playback."
: `Unlock required: ${accessDecision.creditsRequired} credits.`
}</div>`;
return layout({
title: "Audiobook",
content,
});
}
module.exports = {
layout,
renderHomePage,
renderAudioPage,
};

33
test/views.test.js Normal file
View File

@@ -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/);
});