Files
ascii-art-app/static/js/app.js
T
rmancinasandClaude Fable 5 5e33a38dbb
Build and Deploy / Build Image (push) Successful in 31s
Build and Deploy / Deploy to Portainer (push) Successful in 6s
feat: redesign UI with themes, animations, procedural ASCII engine
- Split app.py into routes + ascii_engine.py (stdlib-only procedural
  generation: figlet banners, creatures, patterns, scenes, classics)
- API: /api/random?category=..., /api/categories, richer JSON payload
- New frontend: templates/index.html + static/ (vanilla JS/CSS)
- 4 themes (graphite/paper/phosphor/amber), localStorage persist,
  no-flash boot, prefers-color-scheme default
- Scramble-resolve art transition, reduced-motion support
- Responsive: rail collapses to chips <760px, art auto-fits width
- Fix grid blowout on mobile (min-width: 0 on stage children)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:18:14 -07:00

228 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* asciigen frontend — vanilla JS, no dependencies. */
(function () {
"use strict";
var artEl = document.getElementById("art");
var scrollEl = document.getElementById("artScroll");
var stTitle = document.getElementById("stTitle");
var stDims = document.getElementById("stDims");
var stMsg = document.getElementById("stMsg");
var genBtn = document.getElementById("genBtn");
var copyBtn = document.getElementById("copyBtn");
var copyLabel = document.getElementById("copyLabel");
var current = window.__initial || { art: artEl.textContent, title: "", cols: 0, rows: 0 };
var activeCat = "any";
var busy = false;
var msgTimer = null;
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
/* ---------------------------------------------------------------------
Fit-to-width: scale art font down so the widest line fits the frame,
flooring at a readable minimum (horizontal scroll takes over below).
--------------------------------------------------------------------- */
var ART_BASE = 15;
var ART_MIN = 7;
function fitArt() {
artEl.style.fontSize = ART_BASE + "px";
var avail = scrollEl.clientWidth;
var need = artEl.scrollWidth;
if (need > avail && need > 0) {
var size = Math.max(ART_MIN, Math.floor(ART_BASE * (avail / need) * 10) / 10);
artEl.style.fontSize = size + "px";
}
}
var resizeTimer = null;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(fitArt, 120);
});
/* ---------------------------------------------------------------------
Scramble-resolve transition. Whitespace stays fixed so the silhouette
appears instantly; visible characters resolve at staggered times.
--------------------------------------------------------------------- */
var GLYPHS = "#@%&$?!<>[]{}/\\|=+*~^;:";
function setArt(text) {
artEl.textContent = text;
fitArt();
if (reduceMotion.matches) {
return Promise.resolve();
}
var chars = Array.from(text);
var resolveAt = chars.map(function (c) {
return c === "\n" || c === " " ? 0 : 120 + Math.random() * 480;
});
var t0 = performance.now();
return new Promise(function (done) {
function frame(t) {
var dt = t - t0;
var out = "";
var pending = false;
for (var i = 0; i < chars.length; i++) {
var c = chars[i];
if (c === "\n" || c === " " || dt >= resolveAt[i]) {
out += c;
} else {
pending = true;
out += GLYPHS[(Math.random() * GLYPHS.length) | 0];
}
}
artEl.textContent = out;
if (pending) {
requestAnimationFrame(frame);
} else {
artEl.textContent = text;
done();
}
}
requestAnimationFrame(frame);
});
}
/* ---------------------------------------------------------------------
Status readout
--------------------------------------------------------------------- */
function msg(text, hot) {
clearTimeout(msgTimer);
stMsg.textContent = text;
stMsg.classList.toggle("is-hot", !!hot);
if (hot) {
msgTimer = setTimeout(function () {
stMsg.textContent = "ready";
stMsg.classList.remove("is-hot");
}, 1600);
}
}
/* ---------------------------------------------------------------------
Generate
--------------------------------------------------------------------- */
function generate() {
if (busy) return;
busy = true;
genBtn.disabled = true;
msg("tuning…", true);
var q = activeCat === "any" ? "" : "?category=" + encodeURIComponent(activeCat);
fetch("/api/random" + q)
.then(function (r) {
if (!r.ok) throw new Error("http " + r.status);
return r.json();
})
.then(function (d) {
current = d;
stTitle.textContent = d.title;
stDims.textContent = d.cols + "×" + d.rows;
return setArt(d.art);
})
.then(function () {
msg("ready");
})
.catch(function () {
msg("signal lost", true);
})
.then(function () {
busy = false;
genBtn.disabled = false;
});
}
/* ---------------------------------------------------------------------
Copy to clipboard (with execCommand fallback for non-secure contexts)
--------------------------------------------------------------------- */
function copyArt() {
var text = current.art || artEl.textContent;
var ok = function () {
copyLabel.textContent = "copied ✓";
msg("copied", true);
setTimeout(function () { copyLabel.textContent = "copy"; }, 1400);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(ok, function () { fallbackCopy(text, ok); });
} else {
fallbackCopy(text, ok);
}
}
function fallbackCopy(text, ok) {
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
ok();
} catch (e) {
msg("copy failed", true);
}
document.body.removeChild(ta);
}
/* ---------------------------------------------------------------------
Categories
--------------------------------------------------------------------- */
var catBtns = Array.prototype.slice.call(document.querySelectorAll(".cat"));
catBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
activeCat = btn.dataset.cat;
catBtns.forEach(function (b) { b.classList.toggle("is-active", b === btn); });
generate();
});
});
/* ---------------------------------------------------------------------
Themes
--------------------------------------------------------------------- */
var THEMES = ["graphite", "paper", "phosphor", "amber"];
var themeBtns = Array.prototype.slice.call(document.querySelectorAll(".theme-btn"));
function applyTheme(name) {
document.documentElement.dataset.theme = name;
try { localStorage.setItem("asciigen.theme", name); } catch (e) {}
themeBtns.forEach(function (b) {
b.classList.toggle("is-active", b.dataset.setTheme === name);
});
}
themeBtns.forEach(function (btn) {
btn.addEventListener("click", function () { applyTheme(btn.dataset.setTheme); });
});
function cycleTheme() {
var cur = document.documentElement.dataset.theme;
var idx = THEMES.indexOf(cur);
applyTheme(THEMES[(idx + 1) % THEMES.length]);
}
/* mark the boot theme's button as active */
applyTheme(document.documentElement.dataset.theme || "graphite");
/* ---------------------------------------------------------------------
Keyboard: space = generate, c = copy, t = cycle theme
--------------------------------------------------------------------- */
document.addEventListener("keydown", function (e) {
if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (e.target.tagName || "").toLowerCase();
var interactive = tag === "button" || tag === "input" || tag === "textarea" || tag === "select";
if (e.code === "Space" && !interactive) {
e.preventDefault();
generate();
} else if (e.key === "c" && !interactive) {
copyArt();
} else if (e.key === "t" && !interactive) {
cycleTheme();
}
});
genBtn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyArt);
fitArt();
})();