From 1cdb7b6b9829e7bf043672a2a04756f323f53de9 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 20 Mar 2026 21:33:01 -0700 Subject: [PATCH] Refactor: full vanilla app state + DOM rendering --- assets/js/app.js | 389 ++++++++++++++++++++++++++++++----------------- 1 file changed, 249 insertions(+), 140 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index a09efbb..e1c7708 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,146 +1,255 @@ import { generateImageFrame } from "./openrouter.js"; import { buildGifFromFrames } from "./gif.js"; -import { firstPrompt, nextFramePrompt, clampForm } from "./ui.js"; +import { firstPrompt, nextFramePrompt } from "./ui.js"; const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview"; -window.vibeGifApp = function () { - return { - settingsOpen: false, - loading: false, - error: "", - apiKeyInput: "", - apiKey: "", - progressLabel: "", - progressPct: 0, - frames: [], - gifUrl: "", - - form: { - model: GEMINI_MODEL, - userPrompt: "", - frameCount: 4, - fps: 6, - imageSize: "1K", - aspectRatio: "1:1" - }, - - isGeminiModel(model) { - return model === GEMINI_MODEL; - }, - - refreshIcons() { - const L = window.lucide; - if (!L?.createIcons) return; - - try { - // Newer lucide builds can require icons object - L.createIcons({ icons: L.icons }); - } catch (_) { - // Backward compatibility fallback - try { L.createIcons(); } catch {} - } - }, - - init() { - this.apiKey = localStorage.getItem("openrouter_api_key") || ""; - this.apiKeyInput = this.apiKey || ""; - - this.$nextTick(() => this.refreshIcons()); - window.addEventListener("load", () => this.refreshIcons()); - document.addEventListener("alpine:initialized", () => this.refreshIcons()); - - this.$watch("settingsOpen", () => { - this.$nextTick(() => this.refreshIcons()); - }); - - this.$watch("form.model", (model) => { - if (!this.isGeminiModel(model) && this.form.imageSize !== "1K") { - this.form.imageSize = "1K"; - } - if (this.isGeminiModel(model) && !["1K", "0.5K"].includes(this.form.imageSize)) { - this.form.imageSize = "1K"; - } - }); - }, - - saveApiKey() { - this.apiKey = this.apiKeyInput.trim(); - localStorage.setItem("openrouter_api_key", this.apiKey); - this.settingsOpen = false; - }, - - clearApiKey() { - this.apiKey = ""; - this.apiKeyInput = ""; - localStorage.removeItem("openrouter_api_key"); - }, - - async generate() { - this.error = ""; - this.gifUrl = ""; - this.frames = []; - this.progressLabel = ""; - this.progressPct = 0; - - clampForm(this.form); - - if (!this.apiKey) { - this.error = "Add your OpenRouter API key first (panel-left icon)."; - this.settingsOpen = true; - return; - } - - if (!this.form.userPrompt) { - this.error = "Please enter a simple prompt (e.g. rolling cat)."; - return; - } - - this.loading = true; - - try { - const total = this.form.frameCount; - - const p1 = firstPrompt(this.form.userPrompt); - this.progressLabel = `Generating frame 1/${total}...`; - - const frame1 = await generateImageFrame({ - apiKey: this.apiKey, - model: this.form.model, - textPrompt: p1, - previousFrames: [], - imageSize: this.form.imageSize, - aspectRatio: this.form.aspectRatio - }); - - this.frames.push(frame1); - this.progressPct = Math.round((1 / total) * 100); - - for (let i = 2; i <= total; i++) { - this.progressLabel = `Generating frame ${i}/${total}...`; - - const next = await generateImageFrame({ - apiKey: this.apiKey, - model: this.form.model, - textPrompt: nextFramePrompt(total), - previousFrames: this.frames.slice(-2), - imageSize: this.form.imageSize, - aspectRatio: this.form.aspectRatio - }); - - this.frames.push(next); - this.progressPct = Math.round((i / total) * 100); - } - - this.progressLabel = "Building GIF..."; - this.gifUrl = await buildGifFromFrames(this.frames, this.form.fps); - this.progressLabel = "Done."; - this.progressPct = 100; - } catch (e) { - this.error = e?.message || "Failed to generate."; - } finally { - this.loading = false; - } - } - }; +const state = { + apiKey: localStorage.getItem("openrouter_api_key") || "", + frames: [], + gifUrl: "", + loading: false }; + +const el = {}; +const q = (id) => document.getElementById(id); + +function clamp(n, min, max, fallback) { + n = Number(n); + if (Number.isNaN(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +function show(node) { + node.classList.remove("hidden"); + if (node.id === "settings-modal") node.classList.add("flex"); +} + +function hide(node) { + node.classList.add("hidden"); + if (node.id === "settings-modal") node.classList.remove("flex"); +} + +function setError(msg = "") { + if (!msg) { + el.error.textContent = ""; + hide(el.error); + return; + } + el.error.textContent = msg; + show(el.error); +} + +function setProgress(label = "", pct = 0, visible = false) { + el.progressLabel.textContent = label; + el.progressBar.style.width = `${Math.max(0, Math.min(100, pct))}%`; + visible ? show(el.progressWrap) : hide(el.progressWrap); +} + +function setLoading(v) { + state.loading = !!v; + el.generateBtn.disabled = state.loading; + el.generateBtn.textContent = state.loading ? "Generating…" : "Generate GIF"; +} + +function updateImageSizeOptions() { + const model = el.model.value; + const current = el.imageSize.value; + const options = model === GEMINI_MODEL + ? [ + { value: "1K", label: "1K" }, + { value: "0.5K", label: "0.5K" } + ] + : [{ value: "1K", label: "1K" }]; + + el.imageSize.innerHTML = ""; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt.value; + o.textContent = opt.label; + el.imageSize.appendChild(o); + } + + const allowed = options.map((o) => o.value); + el.imageSize.value = allowed.includes(current) ? current : "1K"; +} + +function renderFrames() { + el.framesGrid.innerHTML = ""; + for (let i = 0; i < state.frames.length; i++) { + const card = document.createElement("div"); + card.className = "rounded-xl border border-ui-border p-2 bg-white"; + + const img = document.createElement("img"); + img.src = state.frames[i]; + img.alt = `frame ${i + 1}`; + img.className = "w-full rounded-lg"; + + const p = document.createElement("p"); + p.className = "mt-1 text-xs text-ui-sub"; + p.textContent = `Frame ${i + 1}`; + + card.appendChild(img); + card.appendChild(p); + el.framesGrid.appendChild(card); + } +} + +function renderGif() { + if (!state.gifUrl) { + hide(el.gifWrap); + show(el.emptyOutput); + return; + } + el.gifImg.src = state.gifUrl; + el.gifDownload.href = state.gifUrl; + show(el.gifWrap); + hide(el.emptyOutput); +} + +function openSettings() { + el.apiKeyInput.value = state.apiKey; + show(el.settingsModal); +} + +function closeSettings() { + hide(el.settingsModal); +} + +function saveApiKey() { + state.apiKey = el.apiKeyInput.value.trim(); + localStorage.setItem("openrouter_api_key", state.apiKey); + closeSettings(); +} + +function clearApiKey() { + state.apiKey = ""; + el.apiKeyInput.value = ""; + localStorage.removeItem("openrouter_api_key"); +} + +async function generate() { + setError(""); + setLoading(true); + setProgress("", 0, true); + + if (state.gifUrl) URL.revokeObjectURL(state.gifUrl); + state.gifUrl = ""; + state.frames = []; + renderGif(); + renderFrames(); + + const model = el.model.value; + const userPrompt = el.userPrompt.value.trim(); + const frameCount = clamp(el.frameCount.value, 2, 24, 4); + const fps = clamp(el.fps.value, 1, 24, 6); + const imageSize = el.imageSize.value; + const aspectRatio = el.aspectRatio.value; + + if (!state.apiKey) { + setError("Add your OpenRouter API key first (☰ button)."); + openSettings(); + setLoading(false); + setProgress("", 0, false); + return; + } + + if (!userPrompt) { + setError("Please enter a simple prompt (e.g. rolling cat)."); + setLoading(false); + setProgress("", 0, false); + return; + } + + try { + const p1 = firstPrompt(userPrompt); + setProgress(`Generating frame 1/${frameCount}...`, Math.round(100 / frameCount), true); + + const frame1 = await generateImageFrame({ + apiKey: state.apiKey, + model, + textPrompt: p1, + previousFrames: [], + imageSize, + aspectRatio + }); + + state.frames.push(frame1); + renderFrames(); + + for (let i = 2; i <= frameCount; i++) { + setProgress(`Generating frame ${i}/${frameCount}...`, Math.round((i / frameCount) * 100), true); + + const next = await generateImageFrame({ + apiKey: state.apiKey, + model, + textPrompt: nextFramePrompt(frameCount), + previousFrames: state.frames.slice(-2), + imageSize, + aspectRatio + }); + + state.frames.push(next); + renderFrames(); + } + + setProgress("Building GIF...", 100, true); + state.gifUrl = await buildGifFromFrames(state.frames, fps); + renderGif(); + setProgress("Done.", 100, true); + } catch (e) { + setError(e?.message || "Failed to generate."); + setProgress("", 0, false); + } finally { + setLoading(false); + } +} + +function bind() { + el.openSettingsBtn.addEventListener("click", openSettings); + el.closeSettingsBtn.addEventListener("click", closeSettings); + el.saveApiKeyBtn.addEventListener("click", saveApiKey); + el.clearApiKeyBtn.addEventListener("click", clearApiKey); + el.generateBtn.addEventListener("click", generate); + el.model.addEventListener("change", updateImageSizeOptions); + + el.settingsModal.addEventListener("click", (e) => { + if (e.target === el.settingsModal) closeSettings(); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeSettings(); + }); +} + +function init() { + el.model = q("model"); + el.userPrompt = q("user-prompt"); + el.frameCount = q("frame-count"); + el.fps = q("fps"); + el.imageSize = q("image-size"); + el.aspectRatio = q("aspect-ratio"); + el.generateBtn = q("generate-btn"); + el.error = q("error"); + el.progressWrap = q("progress-wrap"); + el.progressLabel = q("progress-label"); + el.progressBar = q("progress-bar"); + el.gifWrap = q("gif-wrap"); + el.gifImg = q("gif-img"); + el.gifDownload = q("gif-download"); + el.emptyOutput = q("empty-output"); + el.framesGrid = q("frames-grid"); + + el.settingsModal = q("settings-modal"); + el.openSettingsBtn = q("open-settings-btn"); + el.closeSettingsBtn = q("close-settings-btn"); + el.apiKeyInput = q("api-key-input"); + el.saveApiKeyBtn = q("save-api-key-btn"); + el.clearApiKeyBtn = q("clear-api-key-btn"); + + updateImageSizeOptions(); + renderGif(); + bind(); +} + +document.addEventListener("DOMContentLoaded", init);