feat: add mobile-first daisyui server-rendered UI components
This commit is contained in:
93
src/views/pages.js
Normal file
93
src/views/pages.js
Normal file
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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
33
test/views.test.js
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user