Feat: full single-page UI scaffold

This commit is contained in:
2026-03-20 21:13:37 -07:00
parent 22a612c619
commit 93b987dedc

View File

@@ -1,210 +1,197 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="h-full">
<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.0" />
<title>vibegif.lol</title> <title>vibegif.lol</title>
<meta
name="description" <!-- Tailwind -->
content="AI generated GIFs with BYOK OpenRouter. Minimal kawaii line doodles."
/>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
fontFamily: { colors: {
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"], ui: {
}, bg: "#fafafa",
}, panel: "#ffffff",
}, border: "#e5e7eb",
text: "#111827",
sub: "#6b7280",
accent: "#374151"
}
}
}
}
}; };
</script> </script>
<link rel="stylesheet" href="./assets/css/styles.css" />
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script> <!-- Alpine -->
<script src="https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="./assets/js/app.js" defer></script>
<!-- Lucide -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- gif.js + worker -->
<script src="https://cdn.jsdelivr.net/npm/gif.js.optimized/dist/gif.js"></script>
<link rel="stylesheet" href="./assets/css/styles.css" />
</head> </head>
<body class="bg-gray-50 text-gray-900" x-data="vibeGifApp" x-init="init()"> <body class="h-full bg-ui-bg text-ui-text">
<div class="min-h-screen"> <div x-data="vibeGifApp()" x-init="init()" class="min-h-full">
<header class="border-b border-gray-200 bg-white/90 backdrop-blur"> <!-- Top bar -->
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3"> <header class="sticky top-0 z-20 border-b border-ui-border bg-ui-panel/95 backdrop-blur">
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<button <button
class="inline-flex items-center justify-center rounded-lg border border-gray-200 p-2 text-gray-600 hover:bg-gray-100" @click="settingsOpen = true"
@click="openSettings()" class="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-ui-border hover:bg-gray-100"
title="Account settings" title="Account settings"
> >
<i data-lucide="panel-left" class="h-5 w-5"></i> <i data-lucide="panel-left" class="h-5 w-5 text-ui-accent"></i>
</button> </button>
<div class="text-lg font-semibold tracking-tight">vibegif.lol</div> <div>
<div class="text-xs text-gray-500"> <h1 class="text-xl font-semibold leading-tight">vibegif.lol</h1>
<span x-show="hasApiKey" x-cloak>BYOK connected</span> <p class="text-xs text-ui-sub">AI Generated Gifs (BYOK OpenRouter)</p>
<span x-show="!hasApiKey" x-cloak>No API key</span> </div>
</div>
<div class="text-xs text-ui-sub">
Stack: Tailwind · Alpine · Lucide
</div> </div>
</div> </div>
</header> </header>
<main class="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-2"> <main class="mx-auto max-w-6xl px-4 py-6 grid gap-6 lg:grid-cols-3">
<!-- Controls --> <!-- Controls -->
<section class="card p-5"> <section class="lg:col-span-1 rounded-2xl border border-ui-border bg-ui-panel p-4 space-y-4">
<h1 class="mb-1 text-xl font-semibold">Generate a vibe GIF</h1> <h2 class="font-semibold">Generate</h2>
<p class="mb-5 text-sm text-gray-600">
Use a very simple prompt like: <code class="chip">rolling cat</code>
</p>
<div x-show="!hasApiKey" x-cloak class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"> <div class="space-y-1">
OpenRouter API key is required. <label class="text-sm text-ui-sub">Model</label>
<button class="ml-1 underline" @click="openSettings()">Add it in Account Settings</button>. <select x-model="form.model" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2">
<option value="google/gemini-3.1-flash-image-preview">google/gemini-3.1-flash-image-preview</option>
<option value="bytedance-seed/seedream-4.5">bytedance-seed/seedream-4.5</option>
</select>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="space-y-1">
<label class="field"> <label class="text-sm text-ui-sub">Simple user prompt</label>
<span class="label">Model</span>
<select class="input" x-model="model" @change="normalizeSelections()">
<template x-for="m in modelOptions" :key="m.id">
<option :value="m.id" x-text="m.label"></option>
</template>
</select>
</label>
<label class="field">
<span class="label">Image size</span>
<select class="input" x-model="imageSize">
<template x-for="s in imageSizeOptions" :key="s">
<option :value="s" x-text="s"></option>
</template>
</select>
</label>
<label class="field">
<span class="label">Aspect ratio</span>
<select class="input" x-model="aspectRatio">
<template x-for="r in aspectRatioOptions" :key="r">
<option :value="r" x-text="r"></option>
</template>
</select>
</label>
<label class="field">
<span class="label">Frames</span>
<input class="input" type="number" min="2" max="24" step="1" x-model.number="frameCount" />
</label>
<label class="field">
<span class="label">Framerate (fps)</span>
<input class="input" type="number" min="1" max="24" step="1" x-model.number="fps" />
</label>
</div>
<label class="field mt-4">
<span class="label">Prompt</span>
<input <input
class="input" x-model.trim="form.userPrompt"
type="text" placeholder="e.g. rolling cat"
x-model.trim="userPrompt" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2"
placeholder="rolling cat"
maxlength="120"
/> />
<span class="hint">Keep it short/simple for better frame continuity.</span> <p class="text-xs text-ui-sub">Keep it very simple.</p>
</label>
<label class="field mt-4">
<span class="label">Master Prompt (locked style)</span>
<textarea class="input min-h-[74px]" readonly x-text="masterPrompt"></textarea>
</label>
<div class="mt-5 flex items-center gap-3">
<button
class="inline-flex items-center justify-center rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canGenerate"
@click="generateGif()"
>
<span x-show="!isGenerating">Generate GIF</span>
<span x-show="isGenerating" x-cloak class="flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
Generating…
</span>
</button>
<span class="text-sm text-gray-600" x-text="progressText"></span>
</div> </div>
<p x-show="errorText" x-cloak class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700" x-text="errorText"></p> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-sm text-ui-sub">Frames</label>
<input x-model.number="form.frameCount" type="number" min="2" max="24" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2" />
</div>
<div class="space-y-1">
<label class="text-sm text-ui-sub">FPS</label>
<input x-model.number="form.fps" type="number" min="1" max="24" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-sm text-ui-sub">Image size</label>
<select x-model="form.imageSize" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2">
<option value="1K">1K</option>
<option value="0.5K" x-show="form.model === 'google/gemini-3.1-flash-image-preview'">0.5K</option>
</select>
</div>
<div class="space-y-1">
<label class="text-sm text-ui-sub">Aspect ratio</label>
<select x-model="form.aspectRatio" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2">
<option>1:1</option>
<option>16:9</option>
<option>9:16</option>
<option>4:3</option>
<option>3:4</option>
</select>
</div>
</div>
<button
@click="generate()"
:disabled="loading"
class="w-full rounded-xl bg-gray-900 text-white px-4 py-2.5 disabled:opacity-50"
>
<span x-show="!loading">Generate GIF</span>
<span x-show="loading">Generating…</span>
</button>
<p x-show="error" x-text="error" class="text-sm text-red-600"></p>
<div x-show="loading" class="text-sm text-ui-sub">
<p x-text="progressLabel"></p>
<div class="mt-2 h-2 rounded-full bg-gray-100 overflow-hidden">
<div class="h-full bg-gray-800 transition-all" :style="`width:${progressPct}%`"></div>
</div>
</div>
</section> </section>
<!-- Output --> <!-- Output -->
<section class="card p-5"> <section class="lg:col-span-2 rounded-2xl border border-ui-border bg-ui-panel p-4 space-y-4">
<h2 class="mb-3 text-lg font-semibold">Output</h2> <h2 class="font-semibold">Output</h2>
<div x-show="frames.length === 0 && !isGenerating" class="rounded-lg border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500"> <template x-if="gifUrl">
Your generated frames will appear here. <div class="space-y-3">
</div> <img :src="gifUrl" alt="Generated gif" class="w-full max-w-lg rounded-xl border border-ui-border bg-white" />
<a :href="gifUrl" download="vibegif.gif" class="inline-flex rounded-xl border border-ui-border px-4 py-2 hover:bg-gray-100">
<div x-show="frames.length > 0" x-cloak>
<p class="mb-2 text-sm text-gray-600">
Frames (<span x-text="frames.length"></span>)
</p>
<div class="grid max-h-72 grid-cols-2 gap-2 overflow-auto sm:grid-cols-3">
<template x-for="(f, idx) in frames" :key="idx">
<figure class="rounded-lg border border-gray-200 bg-white p-1">
<img :src="f" alt="" class="h-24 w-full rounded object-contain bg-white" loading="lazy" />
<figcaption class="mt-1 text-center text-[11px] text-gray-500">Frame <span x-text="idx + 1"></span></figcaption>
</figure>
</template>
</div>
</div>
<div x-show="gifUrl" x-cloak class="mt-5 border-t border-gray-200 pt-4">
<p class="mb-2 text-sm text-gray-600">GIF preview</p>
<img :src="gifUrl" alt="Generated gif" class="max-h-80 w-full rounded-lg border border-gray-200 bg-white object-contain" />
<button
class="mt-3 inline-flex items-center justify-center rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100"
@click="downloadGif()"
>
<i data-lucide="download" class="mr-2 h-4 w-4"></i>
Download GIF Download GIF
</button> </a>
</div>
</template>
<template x-if="!gifUrl">
<p class="text-ui-sub text-sm">No GIF yet.</p>
</template>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3" x-show="frames.length">
<template x-for="(frame, idx) in frames" :key="idx">
<div class="rounded-xl border border-ui-border p-2 bg-white">
<img :src="frame" class="w-full rounded-lg" :alt="`frame ${idx+1}`" />
<p class="mt-1 text-xs text-ui-sub">Frame <span x-text="idx+1"></span></p>
</div>
</template>
</div> </div>
</section> </section>
</main> </main>
<!-- Settings Modal -->
<div x-show="settingsOpen" x-cloak class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
<div @click.outside="settingsOpen=false" class="w-full max-w-md rounded-2xl border border-ui-border bg-white p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-semibold">Account Settings</h3>
<button @click="settingsOpen=false" class="rounded-lg p-2 hover:bg-gray-100">
<i data-lucide="x" class="h-4 w-4"></i>
</button>
</div> </div>
<!-- Account Settings Modal --> <label class="text-sm text-ui-sub">OpenRouter API Key</label>
<div
x-show="showSettings"
x-cloak
x-transition.opacity
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
@click.self="closeSettings()"
>
<div class="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-5 shadow-xl" @click.stop>
<div class="mb-3 flex items-center gap-2">
<i data-lucide="key-round" class="h-4 w-4 text-gray-500"></i>
<h3 class="text-base font-semibold">Account Settings</h3>
</div>
<label class="field">
<span class="label">OpenRouter API key</span>
<input <input
class="input"
type="password"
x-model.trim="apiKeyInput" x-model.trim="apiKeyInput"
type="password"
placeholder="sk-or-v1-..." placeholder="sk-or-v1-..."
autocomplete="off" class="w-full rounded-xl border border-ui-border bg-white px-3 py-2"
/> />
<span class="hint">Stored in localStorage on this browser only.</span>
</label>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="flex gap-2">
<button class="rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100" @click="clearApiKey()">Clear</button> <button @click="saveApiKey()" class="rounded-xl bg-gray-900 text-white px-4 py-2">Save</button>
<button class="rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100" @click="closeSettings()">Cancel</button> <button @click="clearApiKey()" class="rounded-xl border border-ui-border px-4 py-2">Clear</button>
<button class="rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-black" @click="saveApiKey()">Save</button> </div>
<p class="text-xs text-ui-sub">Stored in localStorage on this browser.</p>
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="./assets/js/app.js"></script>
</body> </body>
</html> </html>