mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 02:12:12 +00:00
Feat: full single-page UI scaffold
This commit is contained in:
307
index.html
307
index.html
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user