update: redesign pages with unified shell and clean ui
This commit is contained in:
@@ -9,8 +9,28 @@ function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function shell({ title, content, compact = false }) {
|
||||
function shell({ title, content, compact = false, user = null }) {
|
||||
const container = compact ? "max-w-md" : "max-w-5xl";
|
||||
const authenticated = user?.authenticated;
|
||||
const userId = user?.userId;
|
||||
|
||||
const authButtons = authenticated
|
||||
? `<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium opacity-70 hidden sm:inline-block">@${escapeHtml(userId)}</span>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button class="btn btn-sm btn-ghost">Log out</button>
|
||||
</form>
|
||||
</div>`
|
||||
: `<div class="flex items-center gap-2">
|
||||
<a class="btn btn-sm btn-ghost" href="/login">Sign in</a>
|
||||
<a class="btn btn-sm btn-primary shadow-sm" href="/login">Get Started</a>
|
||||
</div>`;
|
||||
|
||||
const menuItems = `
|
||||
<li><a href="/#how">How it works</a></li>
|
||||
<li><a href="/#pricing">Pricing</a></li>
|
||||
<li><a href="/app">Dashboard</a></li>
|
||||
`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
@@ -28,25 +48,31 @@ function shell({ title, content, compact = false }) {
|
||||
<div class="fixed inset-0 -z-10 bg-base-100 selection:bg-primary selection:text-primary-content"></div>
|
||||
|
||||
<nav class="navbar sticky top-0 z-50 bg-base-100/80 backdrop-blur-md border-b border-base-content/5 px-4 md:px-8">
|
||||
<div class="flex-1">
|
||||
<div class="navbar-start">
|
||||
<a href="/" class="btn btn-ghost text-xl font-bold tracking-tight px-0 hover:bg-transparent">XArtAudio</a>
|
||||
</div>
|
||||
<div class="flex-none hidden md:block">
|
||||
<ul class="menu menu-horizontal px-1 gap-2 font-medium">
|
||||
<li><a href="/#how">How it works</a></li>
|
||||
<li><a href="/#pricing">Pricing</a></li>
|
||||
<li><a href="/app">Dashboard</a></li>
|
||||
<div class="navbar-center hidden md:flex">
|
||||
<ul class="menu menu-horizontal px-1 gap-2 font-medium text-sm">
|
||||
${menuItems}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-none md:hidden">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div class="navbar-end gap-2">
|
||||
<div class="hidden md:flex">
|
||||
${authButtons}
|
||||
</div>
|
||||
<div class="dropdown dropdown-end md:hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /></svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-50 p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-content/10">
|
||||
<li><a href="/#how">How it works</a></li>
|
||||
<li><a href="/#pricing">Pricing</a></li>
|
||||
<li><a href="/app">Dashboard</a></li>
|
||||
${menuItems}
|
||||
<li class="mt-2 border-t border-base-content/10 pt-2"></li>
|
||||
${authenticated
|
||||
? `<li><span class="text-xs opacity-50">@${escapeHtml(userId)}</span></li>
|
||||
<li><form method="POST" action="/auth/logout" class="w-full"><button class="w-full text-left">Log out</button></form></li>`
|
||||
: `<li><a href="/login">Sign in</a></li>
|
||||
<li><a href="/login" class="font-bold text-primary">Get Started</a></li>`
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +82,7 @@ function shell({ title, content, compact = false }) {
|
||||
${content}
|
||||
</main>
|
||||
|
||||
<footer class="footer footer-center p-10 bg-base-200 text-base-content rounded-t-2xl mt-auto max-w-5xl mx-auto">
|
||||
<footer class="footer footer-center p-10 bg-base-200 text-base-content rounded-t-2xl mt-auto max-w-5xl mx-auto opacity-80">
|
||||
<aside>
|
||||
<p class="font-bold">XArtAudio <br/>Turning articles into audio since 2024</p>
|
||||
<p>Copyright © ${new Date().getFullYear()} - All right reserved</p>
|
||||
@@ -72,106 +98,30 @@ function shell({ title, content, compact = false }) {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function nav({ authenticated, userId }) {
|
||||
// Navigation is now part of the shell, but we might inject user-specific actions here or in the shell.
|
||||
// For this design iteration, I'm moving the main nav into the shell to ensure it's always there and consistent.
|
||||
// The 'content' passed to shell will contain the page specific content.
|
||||
// However, strict separation might be better.
|
||||
// Let's modify the shell strategy:
|
||||
// The Shell should handle the global layout (Navbar + Footer).
|
||||
// The 'Nav' function here can return an empty string or user-specific sub-nav controls if needed.
|
||||
// For 'clean saas', top-right user controls usually sit in the main navbar.
|
||||
|
||||
// To update the shell's user section dynamically without template complexity,
|
||||
// I will inject a "userControls" string into the shell if possible, or just accept the limitation that
|
||||
// the shell logic above is hardcoded.
|
||||
|
||||
// BETTER APPROACH for this functional style:
|
||||
// Redefine shell to accept 'user' prop, or keep 'nav' as a component that returns nothing
|
||||
// but acts as a signal, OR (best): update shell to render the right side based on args.
|
||||
|
||||
// Since I can't easily change the function signature of shell everywhere in one go without breaking
|
||||
// things if I miss one, I will stick to the current pattern but maybe hide the "nav" logic inside shell
|
||||
// if I pass the user info to shell.
|
||||
|
||||
// Looking at the current usage: `renderLandingPage` calls `shell`.
|
||||
// It passes `content`. The `nav` function is called inside `content`.
|
||||
// This is a bit "inside-out".
|
||||
|
||||
// I will effectively clear the `nav` function to strictly return the "User Actions" (Log out / Dashboard button)
|
||||
// and place them in the shell if I could, but since `shell` wraps `content`, `shell` renders BEFORE `content` values are interpolated?
|
||||
// No, `shell` function is called with the interpolated string.
|
||||
|
||||
// I will keep `nav` yielding the "Page Header" if specific to the page, but for "Clean SaaS",
|
||||
// the navbar is usually global.
|
||||
|
||||
// Let's revert the Shell implementation above slightly to allow passing "header actions" or "user info".
|
||||
// Actually, I can just make `nav` return the *Header* component which includes the Navbar,
|
||||
// and remove the Navbar from the `shell` body. This keeps the composition pattern used currently.
|
||||
|
||||
const right = authenticated
|
||||
? `<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium opacity-70 hidden sm:inline-block">@${escapeHtml(userId)}</span>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button class="btn btn-sm btn-ghost">Log out</button>
|
||||
</form>
|
||||
</div>`
|
||||
: `<div class="flex items-center gap-2">
|
||||
<a class="btn btn-sm btn-ghost" href="/login">Sign in</a>
|
||||
<a class="btn btn-sm btn-primary" href="/login">Get Started</a>
|
||||
</div>`;
|
||||
|
||||
return `<nav class="navbar sticky top-0 z-50 bg-base-100/80 backdrop-blur-md border-b border-base-content/5 mb-8 md:mb-12 -mx-4 md:-mx-8 px-4 md:px-8 w-[calc(100%+2rem)] md:w-[calc(100%+4rem)] rounded-b-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown lg:hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52 border border-base-content/10">
|
||||
<li><a href="/#how">How it works</a></li>
|
||||
<li><a href="/#pricing">Pricing</a></li>
|
||||
<li><a href="/app">Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="/" class="btn btn-ghost text-xl font-bold tracking-tight">XArtAudio</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1 font-medium text-sm">
|
||||
<li><a href="/#how">How it works</a></li>
|
||||
<li><a href="/#pricing">Pricing</a></li>
|
||||
<li><a href="/app">Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
${right}
|
||||
</div>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
function renderLandingPage({ authenticated, userId }) {
|
||||
const primaryCta = authenticated
|
||||
? `<a href="/app" class="btn btn-primary btn-lg font-bold">Open Dashboard</a>`
|
||||
? `<a href="/app" class="btn btn-primary btn-lg font-bold shadow-lg hover:shadow-xl transition-all">Open Dashboard</a>`
|
||||
: `<div class="flex flex-col sm:flex-row gap-4 items-center justify-center">
|
||||
<a href="/login" class="btn btn-primary btn-lg font-bold">Start for Free</a>
|
||||
<a href="#how" class="btn btn-ghost btn-lg">Learn more <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg></a>
|
||||
<a href="/login" class="btn btn-primary btn-lg font-bold shadow-lg hover:shadow-xl transition-all">Start for Free</a>
|
||||
<a href="#how" class="btn btn-ghost btn-lg group">Learn more <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg></a>
|
||||
</div>`;
|
||||
|
||||
return shell({
|
||||
title: "XArtAudio | Turn X Articles into Audiobooks",
|
||||
user: { authenticated, userId },
|
||||
content: `
|
||||
|
||||
<section class="py-16 md:py-24 text-center max-w-3xl mx-auto">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-base-200 text-sm font-medium mb-8 text-base-content/70">
|
||||
<span class="badge badge-accent badge-xs">New</span> Webhook-first automation
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-base-200/50 border border-base-content/5 text-sm font-medium mb-8 text-base-content/70">
|
||||
<span class="badge badge-primary badge-xs">New</span> Webhook-first automation
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-6 bg-gradient-to-br from-base-content to-base-content/50 bg-clip-text text-transparent">
|
||||
Listen to X Articles.
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-base-content/70 mb-10 leading-relaxed">
|
||||
<p class="text-xl md:text-2xl text-base-content/70 mb-10 leading-relaxed max-w-2xl mx-auto">
|
||||
Mention our bot under any long-form X post. We'll convert it to audio and send you a link instantly.
|
||||
</p>
|
||||
${primaryCta}
|
||||
<p class="mt-6 text-sm text-base-content/50">No credit card required for trial • ${escapeHtml(userId ? `Logged in as @${userId}` : "Public access valid")}</p>
|
||||
<p class="mt-6 text-sm text-base-content/40 font-mono">No credit card required • ${escapeHtml(userId ? `Logged in as @${userId}` : "Public access valid")}</p>
|
||||
</section>
|
||||
|
||||
<section id="how" class="py-16 border-t border-base-content/5">
|
||||
@@ -181,16 +131,16 @@ function renderLandingPage({ authenticated, userId }) {
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div class="card bg-base-100 border border-base-content/10">
|
||||
<div class="card bg-base-100 border border-base-content/10 hover:border-base-content/20 transition-colors">
|
||||
<div class="card-body items-center text-center">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg mb-2">1. Mention the bot</h3>
|
||||
<p class="text-base-content/70">Reply to any long article on X with <code>@XArtAudio</code>.</p>
|
||||
<p class="text-base-content/70">Reply to any long article on X with <code class="bg-base-200 px-1 py-0.5 rounded">@XArtAudio</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 border border-base-content/10">
|
||||
<div class="card bg-base-100 border border-base-content/10 hover:border-base-content/20 transition-colors">
|
||||
<div class="card-body items-center text-center">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /></svg>
|
||||
@@ -199,7 +149,7 @@ function renderLandingPage({ authenticated, userId }) {
|
||||
<p class="text-base-content/70">Our webhook verifies the article and generates high-quality audio.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 border border-base-content/10">
|
||||
<div class="card bg-base-100 border border-base-content/10 hover:border-base-content/20 transition-colors">
|
||||
<div class="card-body items-center text-center">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
@@ -227,7 +177,7 @@ function renderLandingPage({ authenticated, userId }) {
|
||||
<li class="flex items-center gap-2"><svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Permanent Storage</li>
|
||||
<li class="flex items-center gap-2"><svg class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> Shareable Links</li>
|
||||
</ul>
|
||||
<a href="/login" class="btn btn-primary w-full">Get Started</a>
|
||||
<a href="/login" class="btn btn-primary w-full shadow-md hover:shadow-lg">Get Started</a>
|
||||
</div>
|
||||
<div class="bg-base-300/50 p-8 md:p-12 flex flex-col justify-center">
|
||||
<h4 class="font-bold mb-4">Credit Logic</h4>
|
||||
@@ -254,127 +204,142 @@ function renderLandingPage({ authenticated, userId }) {
|
||||
}
|
||||
|
||||
function renderLoginPage({ returnTo = "/app", error = null }) {
|
||||
const errorBlock = error ? `<div class="alert alert-error mb-3">${escapeHtml(error)}</div>` : "";
|
||||
const errorBlock = error ? `<div role="alert" class="alert alert-error mb-6 text-sm py-2 px-4 rounded-lg flex items-center gap-2"><svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span>${escapeHtml(error)}</span></div>` : "";
|
||||
|
||||
return shell({
|
||||
title: "Sign in | XArtAudio",
|
||||
compact: true,
|
||||
user: null,
|
||||
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">Use your email account or continue with X.</p>
|
||||
${errorBlock}
|
||||
<form method="POST" action="/auth/x" class="mb-4">
|
||||
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
||||
<button class="btn btn-accent w-full">Continue with X</button>
|
||||
</form>
|
||||
<div class="divider text-xs uppercase text-slate-400">Email</div>
|
||||
<form method="POST" action="/auth/email/sign-in" 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">Email</span>
|
||||
<input name="email" type="email" required class="input input-bordered w-full bg-slate-950" placeholder="you@domain.com" />
|
||||
</label>
|
||||
<label class="form-control w-full">
|
||||
<span class="label-text text-slate-200 mb-1">Password</span>
|
||||
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
|
||||
</label>
|
||||
<button class="btn btn-primary w-full">Sign in with email</button>
|
||||
</form>
|
||||
<div class="divider text-xs uppercase text-slate-400">Create account</div>
|
||||
<form method="POST" action="/auth/email/sign-up" 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">Name</span>
|
||||
<input name="name" required minlength="2" maxlength="80" class="input input-bordered w-full bg-slate-950" placeholder="Matiss" />
|
||||
</label>
|
||||
<label class="form-control w-full">
|
||||
<span class="label-text text-slate-200 mb-1">Email</span>
|
||||
<input name="email" type="email" required class="input input-bordered w-full bg-slate-950" placeholder="you@domain.com" />
|
||||
</label>
|
||||
<label class="form-control w-full">
|
||||
<span class="label-text text-slate-200 mb-1">Password</span>
|
||||
<input name="password" type="password" required minlength="8" maxlength="128" class="input input-bordered w-full bg-slate-950" placeholder="••••••••" />
|
||||
</label>
|
||||
<button class="btn btn-outline w-full">Create account</button>
|
||||
</form>
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
|
||||
<p class="text-base-content/60">Sign in to your account to continue</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-content/10 shadow-sm">
|
||||
<div class="card-body p-6">
|
||||
${errorBlock}
|
||||
<form method="POST" action="/auth/dev-login" class="space-y-4">
|
||||
<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Username</span>
|
||||
</label>
|
||||
<input name="userId" required minlength="2" maxlength="40" class="input input-bordered w-full" placeholder="Enter your username" />
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary w-full shadow-sm hover:shadow">Sign in</button>
|
||||
</form>
|
||||
|
||||
<div class="divider text-xs text-base-content/40 my-6">OR</div>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
<p class="text-base-content/60">Don't have an account? <a href="#" class="link link-primary font-medium hover:underline">Contact us</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-xs text-base-content/40 mt-6 font-mono">Dev mode: Enter any username to create a session.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppPage({ userId, summary, jobs, flash = null }) {
|
||||
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-4"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
|
||||
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-6 flex items-center gap-2 text-sm shadow-sm"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
|
||||
|
||||
const jobsMarkup = jobs.length === 0
|
||||
? `<div class="text-center py-10 text-base-content/60 bg-base-200 rounded-box">
|
||||
<p>No generated audiobooks yet.</p>
|
||||
<p class="text-sm mt-1">Use the simulation form below or call the bot on X.</p>
|
||||
</div>`
|
||||
: `<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
? `<div class="text-center py-16 bg-base-100 border border-base-content/10 rounded-xl">
|
||||
<div class="w-16 h-16 bg-base-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg mb-1">No audiobooks yet</h3>
|
||||
<p class="text-sm text-base-content/60">Use the simulation form below to generate your first audiobook.</p>
|
||||
</div>`
|
||||
: `<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
${jobs.map((job) => `
|
||||
<a class="card bg-base-100 shadow-md hover:shadow-xl transition-all border border-base-200" href="/audio/${job.assetId}">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="font-bold text-lg truncate" title="${escapeHtml(job.article.title)}">${escapeHtml(job.article.title)}</h3>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span class="badge badge-sm badge-ghost">credits: ${job.creditsCharged}</span>
|
||||
<span class="text-xs uppercase font-mono opacity-70">${escapeHtml(job.status)}</span>
|
||||
<a class="card bg-base-100 shadow-sm hover:shadow-md hover:border-primary/20 transition-all border border-base-content/10 group h-full" href="/audio/${job.assetId}">
|
||||
<div class="card-body p-5">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="badge badge-sm badge-ghost font-mono text-xs opacity-70">${escapeHtml(job.status)}</div>
|
||||
<span class="text-xs font-mono opacity-50 text-right">${job.creditsCharged} cr</span>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg leading-tight group-hover:text-primary transition-colors line-clamp-2" title="${escapeHtml(job.article.title)}">${escapeHtml(job.article.title)}</h3>
|
||||
</div>
|
||||
</a>`).join("")}
|
||||
</div>`;
|
||||
|
||||
return shell({
|
||||
title: "Dashboard | XArtAudio",
|
||||
user: { authenticated: true, userId },
|
||||
content: `
|
||||
${nav({ authenticated: true, userId })}
|
||||
${flashMarkup}
|
||||
<section class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 mb-8">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Credits</div>
|
||||
<div class="stat-value text-primary">${summary.balance}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Audiobooks</div>
|
||||
<div class="stat-value text-secondary">${summary.totalJobs}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Spent</div>
|
||||
<div class="stat-value">${summary.totalCreditsSpent}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2 mb-8">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Top up credits</h2>
|
||||
<p class="text-xs text-base-content/60 mb-2">Dev shortcut for MVP. Production uses Polar webhook.</p>
|
||||
<form method="POST" action="/app/actions/topup" class="join w-full">
|
||||
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered join-item w-full" />
|
||||
<button class="btn btn-primary join-item">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Simulate mention</h2>
|
||||
<p class="text-xs text-base-content/60 mb-2">Simulates a webhook mention event.</p>
|
||||
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
|
||||
<input name="title" required class="input input-bordered w-full" placeholder="Article title" />
|
||||
<textarea name="body" required rows="3" class="textarea textarea-bordered w-full" placeholder="Paste article text..."></textarea>
|
||||
<button class="btn btn-accent w-full">Generate audiobook</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-end mb-8 gap-4 border-b border-base-content/5 pb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-1">Dashboard</h1>
|
||||
<p class="text-base-content/60">Manage your credits and audiobooks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold mb-4">Recent audiobooks</h2>
|
||||
${jobsMarkup}
|
||||
</section>
|
||||
${flashMarkup}
|
||||
|
||||
<div class="stats shadow-sm border border-base-content/10 w-full bg-base-100 mb-8 rounded-xl overflow-hidden divide-x divide-base-content/5">
|
||||
<div class="stat place-items-center py-6">
|
||||
<div class="stat-title text-sm uppercase tracking-wider opacity-60">Available Credits</div>
|
||||
<div class="stat-value text-primary mt-1 text-4xl">${summary.balance}</div>
|
||||
<div class="stat-desc mt-2">For generating audio</div>
|
||||
</div>
|
||||
<div class="stat place-items-center py-6">
|
||||
<div class="stat-title text-sm uppercase tracking-wider opacity-60">Audiobooks</div>
|
||||
<div class="stat-value text-base-content mt-1 text-4xl">${summary.totalJobs}</div>
|
||||
<div class="stat-desc mt-2">Generated so far</div>
|
||||
</div>
|
||||
<div class="stat place-items-center py-6">
|
||||
<div class="stat-title text-sm uppercase tracking-wider opacity-60">Total Spent</div>
|
||||
<div class="stat-value text-secondary mt-1 text-4xl">${summary.totalCreditsSpent}</div>
|
||||
<div class="stat-desc mt-2">Lifetime credits</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-3 mb-12">
|
||||
<div class="md:col-span-2 space-y-8">
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||
Recent Audiobooks
|
||||
</h2>
|
||||
</div>
|
||||
${jobsMarkup}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="card bg-base-100 shadow-sm border border-base-content/10 sticky top-24">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="card-title text-sm font-bold uppercase tracking-wider opacity-60 mb-2">Developer Actions</h2>
|
||||
|
||||
<form method="POST" action="/app/actions/topup" class="mb-4">
|
||||
<label class="label text-xs font-medium pl-0">Top Up Credits</label>
|
||||
<div class="join w-full">
|
||||
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered input-sm join-item w-full" />
|
||||
<button class="btn btn-primary btn-sm join-item">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
|
||||
<label class="label text-xs font-medium pl-0">Simulate Mention</label>
|
||||
<input name="title" required class="input input-bordered input-sm w-full" placeholder="Article Title" />
|
||||
<textarea name="body" required rows="3" class="textarea textarea-bordered textarea-sm w-full" placeholder="Paste article text..."></textarea>
|
||||
<button class="btn btn-accent btn-sm w-full">Generate</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -384,43 +349,68 @@ function renderAudioPage({ audio, accessDecision, userId, playbackUrl = null })
|
||||
return shell({
|
||||
title: "Audio not found",
|
||||
compact: true,
|
||||
content: `${nav({ authenticated: Boolean(userId), userId })}<div role="alert" class="alert alert-error"><span>Audio not found.</span></div>`,
|
||||
user: { authenticated: Boolean(userId), userId },
|
||||
content: `<div class="max-w-md mx-auto mt-12 text-center"><div role="alert" class="alert alert-error mb-4"><span>Audio not found.</span></div><a href="/app" class="btn btn-ghost">Back to Dashboard</a></div>`,
|
||||
});
|
||||
}
|
||||
|
||||
const action = accessDecision.allowed
|
||||
? `<div role="alert" class="alert alert-success"><span>Access granted. This unlock is permanent for your account.</span></div>`
|
||||
? `<div role="alert" class="alert alert-success shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span><span class="font-bold">Unlocked!</span> Permanent access granted.</span>
|
||||
</div>`
|
||||
: accessDecision.reason === "auth_required"
|
||||
? `<div role="alert" class="alert alert-warning mb-4"><span>Sign in to continue.</span></div>
|
||||
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full">Sign in to unlock</a>`
|
||||
: `<div role="alert" class="alert alert-warning mb-4"><span>Unlock required: ${accessDecision.creditsRequired} credits.</span></div>
|
||||
<form method="POST" action="/audio/${audio.id}/unlock">
|
||||
<button class="btn btn-primary w-full">Pay ${accessDecision.creditsRequired} credits and unlock forever</button>
|
||||
</form>`;
|
||||
? `<div class="card bg-base-200 border border-base-content/10">
|
||||
<div class="card-body items-center text-center p-6">
|
||||
<h3 class="font-bold text-lg mb-2">Sign in to listen</h3>
|
||||
<p class="text-sm mb-4 text-base-content/70">You need an account to unlock this audiobook.</p>
|
||||
<a href="/login?returnTo=/audio/${audio.id}" class="btn btn-primary w-full max-w-xs">Sign in to unlock</a>
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="card bg-base-200 border border-base-content/10">
|
||||
<div class="card-body items-center text-center p-6">
|
||||
<h3 class="font-bold text-lg mb-2">Unlock Audiobook</h3>
|
||||
<p class="text-sm mb-4 text-base-content/70">Unlock this audiobook permanently for <span class="font-bold text-base-content">${accessDecision.creditsRequired} credits</span>.</p>
|
||||
<form method="POST" action="/audio/${audio.id}/unlock" class="w-full max-w-xs">
|
||||
<button class="btn btn-primary w-full">Pay & Listen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return shell({
|
||||
title: `${escapeHtml(audio.articleTitle)} | XArtAudio`,
|
||||
compact: true,
|
||||
user: { authenticated: Boolean(userId), userId },
|
||||
content: `
|
||||
${nav({ authenticated: Boolean(userId), userId })}
|
||||
<div class="card w-full max-w-2xl mx-auto shadow-2xl bg-base-100 border border-base-200">
|
||||
<div class="card-body p-6">
|
||||
<span class="badge badge-accent mb-2">Audiobook</span>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-2 leading-tight">${escapeHtml(audio.articleTitle)}</h1>
|
||||
<div class="text-sm text-base-content/60 mb-6 flex gap-3 text-mono">
|
||||
<span>Duration ~ ${audio.durationSec}s</span>
|
||||
<span>•</span>
|
||||
<span>Asset ${escapeHtml(audio.id.substring(0, 8))}...</span>
|
||||
<div class="mb-6">
|
||||
<a href="/app" class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60 hover:text-base-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card w-full shadow-xl bg-base-100 border border-base-content/10 overflow-hidden">
|
||||
<div class="card-body p-6 md:p-8">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-accent">Audiobook</span>
|
||||
<span class="text-xs font-mono text-base-content/50 uppercase tracking-widest">${escapeHtml(audio.id.substring(0, 8))}</span>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h1 class="text-2xl md:text-3xl font-extrabold mb-4 leading-tight">${escapeHtml(audio.articleTitle)}</h1>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-base-content/60 mb-8 border-b border-base-content/10 pb-6">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
${audio.durationSec}s duration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${action}
|
||||
|
||||
${accessDecision.allowed
|
||||
? `<div class="mt-6">
|
||||
<h3 class="font-bold mb-2">Direct Stream URL</h3>
|
||||
<div class="mockup-code bg-neutral text-neutral-content p-4 text-sm overflow-x-auto">
|
||||
? `<div class="mt-8 pt-6 border-t border-base-content/10">
|
||||
<h3 class="font-bold mb-3 text-xs uppercase tracking-widest opacity-60">Direct Stream URL</h3>
|
||||
<div class="mockup-code bg-base-300 text-base-content p-4 text-xs overflow-x-auto">
|
||||
<pre><code>${escapeHtml(playbackUrl || `stream://${audio.storageKey}`)}</code></pre>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
Reference in New Issue
Block a user