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("'", "'");
}
function layout({ title, content }) {
function shell({ title, content, compact = false }) {
const container = compact ? "max-w-xl" : "max-w-5xl";
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>
<meta name="theme-color" content="#1d232a" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<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">
<body class="min-h-screen bg-slate-950 text-slate-100">
<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}
</main>
<script>
@@ -33,68 +40,198 @@ function layout({ title, content }) {
</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>`;
function nav({ authenticated, userId }) {
const right = authenticated
? `<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>`
: `<a class="btn btn-sm btn-primary" href="/login">Get started</a>`;
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>
return `<header class="navbar bg-slate-900/70 border border-slate-700 rounded-2xl backdrop-blur mb-6 md:mb-8">
<div class="navbar-start">
<a href="/" class="font-semibold tracking-tight">XArtAudio</a>
</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>
</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}
<section id="how" class="grid gap-4 md:grid-cols-3 mb-8 md:mb-12">
<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>
<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>
</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) {
return layout({
return shell({
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
? `<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>`;
let action = "";
if (accessDecision.allowed) {
action = `<div class="alert alert-success">Access granted. This unlock is permanent for your account.</div>`;
} else if (accessDecision.reason === "auth_required") {
action = `<div class="alert alert-warning mb-3">Sign in to continue.</div>
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary">Sign in to unlock</a>`;
} else {
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({
title: "Audiobook",
content,
return shell({
title: `${escapeHtml(audio.articleTitle)} | XArtAudio`,
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 = {
layout,
renderHomePage,
shell,
renderLandingPage,
renderLoginPage,
renderAppPage,
renderAudioPage,
};

View File

@@ -2,33 +2,53 @@
const test = require("node:test");
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", () => {
const html = layout({ title: "t", content: "x" });
test("shell includes daisyui and pwa tags", () => {
const html = shell({ title: "t", content: "x" });
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", () => {
const html = renderHomePage({
authenticated: true,
test("landing page renders hero and flow sections", () => {
const html = renderLandingPage({ authenticated: false, userId: null });
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",
balance: 7,
jobs: [{ assetId: "a1", status: "completed", article: { title: "Hello" } }],
summary: { balance: 4, totalJobs: 2, totalCreditsSpent: 2 },
jobs: [{ assetId: "1", status: "completed", article: { title: "Hello" }, creditsCharged: 1 }],
});
assert.match(html, /Wallet Credits/);
assert.match(html, /7/);
assert.match(html, /Top up credits/);
assert.match(html, /Simulate mention/);
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({
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 },
userId: "u2",
});
assert.match(html, /Unlock required: 3 credits/);
assert.match(html, /Pay 3 credits and unlock forever/);
});