feat: redesign public and authenticated UI with mobile-first daisyui pages

This commit is contained in:
Codex
2026-02-18 12:53:16 +00:00
parent 9c3c626c46
commit 4e443c3507
2 changed files with 217 additions and 60 deletions

View File

@@ -9,19 +9,26 @@ function escapeHtml(value) {
.replaceAll("'", "'"); .replaceAll("'", "'");
} }
function layout({ title, content }) { function shell({ title, content, compact = false }) {
const container = compact ? "max-w-xl" : "max-w-5xl";
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title> <title>${escapeHtml(title)}</title>
<meta name="theme-color" content="#1d232a" /> <meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/dist/full.css" rel="stylesheet" type="text/css" /> <link href="https://cdn.jsdelivr.net/npm/daisyui@5/dist/full.css" rel="stylesheet" type="text/css" />
</head> </head>
<body class="min-h-screen bg-base-200"> <body class="min-h-screen bg-slate-950 text-slate-100">
<main class="max-w-md mx-auto p-4 space-y-4"> <div class="fixed inset-0 -z-10 overflow-hidden">
<div class="absolute -top-24 -left-20 h-72 w-72 rounded-full bg-cyan-500/20 blur-3xl"></div>
<div class="absolute top-40 -right-20 h-80 w-80 rounded-full bg-emerald-500/20 blur-3xl"></div>
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(30,41,59,.65),rgba(2,6,23,1)_45%)]"></div>
</div>
<main class="${container} mx-auto px-4 py-6 md:py-10">
${content} ${content}
</main> </main>
<script> <script>
@@ -33,68 +40,198 @@ function layout({ title, content }) {
</html>`; </html>`;
} }
function renderHomePage({ authenticated, userId, balance, jobs }) { function nav({ authenticated, userId }) {
const authBlock = authenticated const right = authenticated
? `<div class="alert alert-success text-sm">Signed in as <strong>${escapeHtml(userId)}</strong></div>` ? `<div class="flex items-center gap-2"><span class="badge badge-outline">@${escapeHtml(userId)}</span><form method="POST" action="/auth/logout"><button class="btn btn-xs btn-ghost">Log out</button></form></div>`
: `<div class="alert alert-warning text-sm">Not authenticated. Add header <code>x-user-id</code> in API calls.</div>`; : `<a class="btn btn-sm btn-primary" href="/login">Get started</a>`;
const jobsMarkup = jobs.length === 0 return `<header class="navbar bg-slate-900/70 border border-slate-700 rounded-2xl backdrop-blur mb-6 md:mb-8">
? `<p class="text-sm opacity-70">No jobs yet.</p>` <div class="navbar-start">
: `<ul class="menu bg-base-100 rounded-box border border-base-300">${jobs <a href="/" class="font-semibold tracking-tight">XArtAudio</a>
.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 class="navbar-center hidden sm:flex">
<a href="/#how" class="btn btn-ghost btn-sm">How it works</a>
<a href="/#pricing" class="btn btn-ghost btn-sm">Pricing</a>
<a href="/app" class="btn btn-ghost btn-sm">Dashboard</a>
</div>
<div class="navbar-end">${right}</div>
</header>`;
}
function renderLandingPage({ authenticated, userId }) {
const primaryCta = authenticated
? `<a href="/app" class="btn btn-primary btn-lg">Open Dashboard</a>`
: `<a href="/login" class="btn btn-primary btn-lg">Start Free</a>`;
return shell({
title: "XArtAudio | Turn X Articles into Audiobooks",
content: `
${nav({ authenticated, userId })}
<section class="grid gap-4 md:gap-6 md:grid-cols-2 items-center mb-8 md:mb-12">
<div class="space-y-4">
<span class="badge badge-accent">Webhook-first automation</span>
<h1 class="text-4xl md:text-5xl font-black leading-tight">From X Article to audiobook in one mention.</h1>
<p class="text-slate-300 text-base md:text-lg">Mention the bot under a long-form X Article. We verify it, convert it, and return an access-controlled public link.</p>
<div class="flex flex-wrap gap-3">${primaryCta}<a href="/app" class="btn btn-outline btn-lg">See Product</a></div>
<p class="text-xs text-slate-400">Caller pays generation credits. Non-owners can unlock permanently with the same credit amount.</p>
</div>
<div class="card bg-slate-900/70 border border-slate-700 shadow-2xl">
<div class="card-body p-4 md:p-5">
<h2 class="font-bold">Live flow</h2>
<ol class="steps steps-vertical">
<li class="step step-primary">User mentions <code>@YourBot</code> on article thread</li>
<li class="step step-primary">Webhook validates article + charges credits</li>
<li class="step step-primary">Audio generated and linked publicly</li>
<li class="step">Others sign in and unlock access if needed</li>
</ol>
</div> </div>
</div> </div>
</section> </section>
<section class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4"> <section id="how" class="grid gap-4 md:grid-cols-3 mb-8 md:mb-12">
<h2 class="font-semibold">Recent Audiobooks</h2> <article class="card bg-slate-900/70 border border-slate-700"><div class="card-body"><h3 class="font-semibold">For creators</h3><p class="text-sm text-slate-300">Call the bot from X and track generated audiobooks in your dashboard.</p></div></article>
${jobsMarkup} <article class="card bg-slate-900/70 border border-slate-700"><div class="card-body"><h3 class="font-semibold">For listeners</h3><p class="text-sm text-slate-300">Open a public link, authenticate, then unlock once for permanent access.</p></div></article>
<article class="card bg-slate-900/70 border border-slate-700"><div class="card-body"><h3 class="font-semibold">For teams</h3><p class="text-sm text-slate-300">Credit ledger, idempotent webhooks, and predictable per-article pricing.</p></div></article>
</section>
<section id="pricing" class="card bg-slate-900/70 border border-slate-700">
<div class="card-body p-4 md:p-6">
<h2 class="card-title">Pricing logic</h2>
<p class="text-sm text-slate-300">1 credit up to 25,000 chars, then +1 credit for each additional 10,000 chars.</p>
<div class="stats stats-vertical md:stats-horizontal bg-slate-950/70 border border-slate-700 mt-3">
<div class="stat"><div class="stat-title">Included</div><div class="stat-value text-2xl">25k chars</div></div>
<div class="stat"><div class="stat-title">Step</div><div class="stat-value text-2xl">10k</div></div>
<div class="stat"><div class="stat-title">Max size</div><div class="stat-value text-2xl">120k</div></div>
</div>
</div> </div>
</section> </section>
`, `,
}); });
} }
function renderAudioPage({ audio, accessDecision }) { function renderLoginPage({ returnTo = "/app", error = null }) {
const errorBlock = error ? `<div class="alert alert-error mb-3">${escapeHtml(error)}</div>` : "";
return shell({
title: "Sign in | XArtAudio",
compact: true,
content: `
${nav({ authenticated: false, userId: null })}
<section class="card bg-slate-900/80 border border-slate-700 shadow-xl">
<div class="card-body">
<h1 class="text-2xl font-bold">Sign in</h1>
<p class="text-sm text-slate-300">MVP login. Enter a username to create a local session cookie.</p>
${errorBlock}
<form method="POST" action="/auth/dev-login" class="space-y-3">
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
<label class="form-control w-full">
<span class="label-text text-slate-200 mb-1">Username</span>
<input name="userId" required minlength="2" maxlength="40" class="input input-bordered w-full bg-slate-950" placeholder="matiss" />
</label>
<button class="btn btn-primary w-full">Continue</button>
</form>
</div>
</section>
`,
});
}
function renderAppPage({ userId, summary, jobs, flash = null }) {
const flashMarkup = flash ? `<div class="alert alert-info mb-4">${escapeHtml(flash)}</div>` : "";
const jobsMarkup = jobs.length === 0
? `<div class="text-sm text-slate-300">No generated audiobooks yet. Use the simulation form below or call the bot on X.</div>`
: `<div class="space-y-2">${jobs
.map((job) => `<a class="card bg-slate-950/70 border border-slate-700 hover:border-cyan-400 transition-colors" href="/audio/${job.assetId}"><div class="card-body p-3"><h3 class="font-semibold">${escapeHtml(job.article.title)}</h3><p class="text-xs text-slate-400">credits: ${job.creditsCharged} • status: ${escapeHtml(job.status)}</p></div></a>`)
.join("")}</div>`;
return shell({
title: "Dashboard | XArtAudio",
content: `
${nav({ authenticated: true, userId })}
${flashMarkup}
<section class="grid gap-4 md:grid-cols-3 mb-5">
<div class="stat bg-slate-900/70 border border-slate-700 rounded-xl"><div class="stat-title">Credits</div><div class="stat-value">${summary.balance}</div></div>
<div class="stat bg-slate-900/70 border border-slate-700 rounded-xl"><div class="stat-title">Audiobooks</div><div class="stat-value">${summary.totalJobs}</div></div>
<div class="stat bg-slate-900/70 border border-slate-700 rounded-xl"><div class="stat-title">Spent</div><div class="stat-value">${summary.totalCreditsSpent}</div></div>
</section>
<section class="grid gap-4 md:grid-cols-2">
<article class="card bg-slate-900/80 border border-slate-700">
<div class="card-body p-4">
<h2 class="font-bold">Top up credits</h2>
<p class="text-xs text-slate-400">Dev shortcut for MVP. Production uses Polar webhook.</p>
<form method="POST" action="/app/actions/topup" class="flex gap-2">
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered w-full bg-slate-950" />
<button class="btn btn-primary">Add</button>
</form>
</div>
</article>
<article class="card bg-slate-900/80 border border-slate-700">
<div class="card-body p-4">
<h2 class="font-bold">Simulate mention</h2>
<p class="text-xs text-slate-400">Simulates a webhook mention event and article conversion.</p>
<form method="POST" action="/app/actions/simulate-mention" class="space-y-2">
<input name="title" required class="input input-bordered w-full bg-slate-950" placeholder="Article title" />
<textarea name="body" required rows="4" class="textarea textarea-bordered w-full bg-slate-950" placeholder="Paste article text..."></textarea>
<button class="btn btn-accent w-full">Generate audiobook</button>
</form>
</div>
</article>
</section>
<section class="mt-5">
<h2 class="font-bold mb-2">Recent audiobooks</h2>
${jobsMarkup}
</section>
`,
});
}
function renderAudioPage({ audio, accessDecision, userId }) {
if (!audio) { if (!audio) {
return layout({ return shell({
title: "Audio not found", title: "Audio not found",
content: `<div class="alert alert-error">Audio not found.</div>`, compact: true,
content: `${nav({ authenticated: Boolean(userId), userId })}<div class="alert alert-error">Audio not found.</div>`,
}); });
} }
const content = accessDecision.allowed let action = "";
? `<div class="alert alert-success">Access granted. Stream key: <code>${escapeHtml(audio.storageKey)}</code></div> if (accessDecision.allowed) {
<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>` action = `<div class="alert alert-success">Access granted. This unlock is permanent for your account.</div>`;
: `<div class="alert alert-warning">${ } else if (accessDecision.reason === "auth_required") {
accessDecision.reason === "auth_required" action = `<div class="alert alert-warning mb-3">Sign in to continue.</div>
? "Sign in required before playback." <a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary">Sign in to unlock</a>`;
: `Unlock required: ${accessDecision.creditsRequired} credits.` } else {
}</div>`; action = `<div class="alert alert-warning mb-3">Unlock required: ${accessDecision.creditsRequired} credits.</div>
<form method="POST" action="/audio/${audio.id}/unlock">
<button class="btn btn-primary">Pay ${accessDecision.creditsRequired} credits and unlock forever</button>
</form>`;
}
return layout({ return shell({
title: "Audiobook", title: `${escapeHtml(audio.articleTitle)} | XArtAudio`,
content, compact: true,
content: `
${nav({ authenticated: Boolean(userId), userId })}
<section class="card bg-slate-900/85 border border-slate-700">
<div class="card-body p-4">
<h1 class="text-xl font-bold">${escapeHtml(audio.articleTitle)}</h1>
<div class="text-xs text-slate-400">Duration ~ ${audio.durationSec}s • Asset ${escapeHtml(audio.id)}</div>
${action}
${accessDecision.allowed ? `<div class="mockup-code mt-3"><pre><code>stream://${escapeHtml(audio.storageKey)}</code></pre></div>` : ""}
</div>
</section>
`,
}); });
} }
module.exports = { module.exports = {
layout, shell,
renderHomePage, renderLandingPage,
renderLoginPage,
renderAppPage,
renderAudioPage, renderAudioPage,
}; };

View File

@@ -2,33 +2,53 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { layout, renderHomePage, renderAudioPage } = require("../src/views/pages"); const {
shell,
renderLandingPage,
renderLoginPage,
renderAppPage,
renderAudioPage,
} = require("../src/views/pages");
test("layout includes daisyui stylesheet and mobile-first wrapper", () => { test("shell includes daisyui and pwa tags", () => {
const html = layout({ title: "t", content: "x" }); const html = shell({ title: "t", content: "x" });
assert.match(html, /daisyui@5/); assert.match(html, /daisyui@5/);
assert.match(html, /max-w-md mx-auto p-4/); assert.match(html, /manifest\.webmanifest/);
assert.match(html, /manifest.webmanifest/); assert.match(html, /serviceWorker/);
}); });
test("home page renders jobs list and wallet credits", () => { test("landing page renders hero and flow sections", () => {
const html = renderHomePage({ const html = renderLandingPage({ authenticated: false, userId: null });
authenticated: true, assert.match(html, /From X Article to audiobook in one mention/);
assert.match(html, /id="how"/);
assert.match(html, /id="pricing"/);
});
test("login page renders username form", () => {
const html = renderLoginPage({ returnTo: "/audio/1" });
assert.match(html, /action="\/auth\/dev-login"/);
assert.match(html, /name="userId"/);
assert.match(html, /value="\/audio\/1"/);
});
test("app page renders stats and forms", () => {
const html = renderAppPage({
userId: "u1", userId: "u1",
balance: 7, summary: { balance: 4, totalJobs: 2, totalCreditsSpent: 2 },
jobs: [{ assetId: "a1", status: "completed", article: { title: "Hello" } }], jobs: [{ assetId: "1", status: "completed", article: { title: "Hello" }, creditsCharged: 1 }],
}); });
assert.match(html, /Wallet Credits/); assert.match(html, /Top up credits/);
assert.match(html, /7/); assert.match(html, /Simulate mention/);
assert.match(html, /Hello/); assert.match(html, /Hello/);
}); });
test("audio page asks auth/payment when access is denied", () => { test("audio page shows unlock action when payment is required", () => {
const html = renderAudioPage({ const html = renderAudioPage({
audio: { id: "a1", storageKey: "audio/a1.mp3", articleTitle: "A", durationSec: 30 }, audio: { id: "1", storageKey: "audio/1.mp3", articleTitle: "A", durationSec: 30 },
accessDecision: { allowed: false, reason: "payment_required", creditsRequired: 3 }, accessDecision: { allowed: false, reason: "payment_required", creditsRequired: 3 },
userId: "u2",
}); });
assert.match(html, /Unlock required: 3 credits/); assert.match(html, /Pay 3 credits and unlock forever/);
}); });