feat: redesign public and authenticated UI with mobile-first daisyui pages
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user