diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/app.css | 273 | ||||
| -rw-r--r-- | public/app.js | 399 | ||||
| -rw-r--r-- | public/index.html | 39 |
3 files changed, 311 insertions, 400 deletions
diff --git a/public/app.css b/public/app.css index b72761c..308868f 100644 --- a/public/app.css +++ b/public/app.css @@ -1,150 +1,175 @@ :root { - --ink: #111; - --paper: #faf8f5; - --line: #ddd6cc; - --accent: #b3000a; - --mono: "SF Mono", ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; - --serif: Georgia, "Times New Roman", serif; + --bg: #0e0f13; + --tile: #1b1d24; + --ring: #7dd3fc; + --ink: #e6e8ee; + --muted: #8b90a0; } + * { box-sizing: border-box; } -html { background: var(--paper); } -body { + +html, body { margin: 0; + height: 100%; + background: var(--bg); color: var(--ink); - font-family: var(--mono); - font-size: 14px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + overflow: hidden; } -.mast { - max-width: 980px; - margin: 0 auto; - padding: 48px 24px 8px; + +#stage { + position: fixed; + inset: 0; + overflow: hidden; + touch-action: none; + user-select: none; } -.mast h1 { - font-family: var(--serif); - font-weight: 400; - font-size: 44px; - letter-spacing: -0.02em; - margin: 0 0 4px; + +#world { + position: absolute; + left: 0; + top: 0; + transform: translate(0, 0); + transition: transform .5s cubic-bezier(.22, 1, .36, 1); + will-change: transform; } -.mast h1 .dot { color: var(--accent); } -.tag { margin: 0 0 22px; color: #555; font-size: 13px; } -.modes { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } -.modes button { - font: inherit; - background: none; - border: 1px solid var(--line); - border-radius: 999px; - padding: 5px 14px; - color: var(--ink); + +.tile { + position: absolute; + border-radius: 6px; + overflow: hidden; + background: var(--tile); + box-shadow: 0 2px 10px rgba(0, 0, 0, .45); cursor: pointer; + transition: box-shadow .2s; } -.modes button.on { background: var(--ink); color: var(--paper); border-color: var(--ink); } -.status { margin: 0; color: #888; font-size: 12px; min-height: 18px; } - -.stream { max-width: 980px; margin: 0 auto; padding: 12px 24px 40px; } +.tile img { width: 100%; height: 100%; object-fit: cover; display: block; } +.tile.lock { animation: lock .5s cubic-bezier(.22, 1, .36, 1); } -.thread { margin: 34px 0 0; } -.thread-head { - display: flex; - align-items: baseline; - gap: 10px; - margin-bottom: 10px; +@keyframes lock { + 0% { transform: scale(.2); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } } -.thread-head h2 { - font-family: var(--serif); - font-weight: 400; - font-size: 22px; - margin: 0; + +.tile.current { + box-shadow: 0 0 0 2px var(--ring), 0 0 26px 2px rgba(125, 211, 252, .4); } -.thread-head .count { color: var(--accent); font-size: 12px; } -.strip { - display: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 8px; - scrollbar-width: thin; + +.nav { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity .15s; } -.strip a { flex: 0 0 auto; display: block; } -.strip img { - display: block; - width: 140px; - height: 140px; - object-fit: cover; - border-radius: 3px; - background: #eee; - transition: transform 120ms ease; +.tile.current:hover .nav { opacity: 1; } + +.nav-btn { + position: absolute; + pointer-events: auto; + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, .25); + background: rgba(14, 15, 19, .85); + color: var(--ink); + font-size: 15px; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: transform .12s, border-color .12s; } -.strip a:hover img { transform: scale(1.03); } -.swatch { - display: block; - height: 3px; - background: #ccc; - border-radius: 0 0 3px 3px; - margin-top: 2px; +.nav-btn:hover { border-color: var(--ring); transform: scale(1.12); } +.nav-btn[data-dir="N"] { top: 0; left: 50%; translate: -50% -50%; } +.nav-btn[data-dir="E"] { top: 50%; right: 0; translate: 50% -50%; } +.nav-btn[data-dir="S"] { bottom: 0; left: 50%; translate: -50% 50%; } +.nav-btn[data-dir="W"] { top: 50%; left: 0; translate: -50% -50%; } + +.nav-label { + position: absolute; + left: 50%; + translate: -50%; + white-space: nowrap; + font-size: 11px; + letter-spacing: .04em; + color: var(--ink); + background: rgba(14, 15, 19, .92); + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 4px; + padding: 2px 7px; + opacity: 0; + transition: opacity .12s; + pointer-events: none; } +.nav-btn[data-dir="N"] .nav-label { bottom: calc(100% + 8px); } +.nav-btn[data-dir="S"] .nav-label { top: calc(100% + 8px); } +.nav-btn[data-dir="E"] .nav-label { top: 50%; left: calc(100% + 10px); translate: 0 -50%; } +.nav-btn[data-dir="W"] .nav-label { top: 50%; right: calc(100% + 10px); translate: 0 -50%; } +.nav-btn:hover .nav-label { opacity: 1; } -.lightbox { +#hint { position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.82); - display: flex; - align-items: center; - justify-content: center; - z-index: 50; + bottom: 12px; + left: 50%; + translate: -50%; + margin: 0; + font-size: 12px; + color: var(--muted); + letter-spacing: .05em; + pointer-events: none; } -.lightbox[hidden] { display: none; } -.lightbox-inner { - display: flex; - gap: 24px; - max-width: 1180px; - width: calc(100% - 40px); - background: var(--paper); - border-radius: 4px; - padding: 18px; - position: relative; - align-items: flex-start; + +#flash { + position: fixed; + top: 18px; + left: 50%; + translate: -50%; + font-size: 14px; + letter-spacing: .06em; + color: var(--ink); + background: rgba(14, 15, 19, .88); + border: 1px solid rgba(255, 255, 255, .22); + border-radius: 6px; + padding: 6px 14px; + opacity: 0; + pointer-events: none; + transition: opacity .25s; } -.lightbox-inner img { - max-width: min(760px, 72%); - max-height: 82vh; - object-fit: contain; - border-radius: 2px; - background: #000; +#flash.show { opacity: 1; } + +#splash { + position: fixed; + inset: 0; + display: grid; + place-content: center; + justify-items: center; + gap: 14px; + text-align: center; + background: var(--bg); + z-index: 10; + transition: opacity .4s, visibility .4s; } -.lightbox-side { flex: 1; min-width: 220px; padding-right: 30px; } -.lightbox-side h2 { font-family: var(--serif); font-weight: 400; font-size: 24px; margin: 0 0 4px; } -.dim { color: #666; } -.conns { margin-top: 16px; } -.conns h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #888; margin: 0 0 8px; } -.conns .chip { - display: inline-block; - border: 1px solid var(--line); +#splash.hide { opacity: 0; visibility: hidden; pointer-events: none; } +#splash h1 { margin: 0; font-size: 44px; font-weight: 300; letter-spacing: .18em; } +#splash h1 .dot { color: var(--ring); } +#splash p { max-width: 34ch; color: var(--muted); margin: 0; font-size: 15px; line-height: 1.6; } +#start { + margin-top: 10px; + padding: 10px 34px; + font-size: 15px; + letter-spacing: .12em; + border: 1px solid rgba(255, 255, 255, .3); border-radius: 999px; - padding: 4px 12px; - margin: 0 6px 6px 0; - font-size: 12px; - cursor: pointer; -} -.conns .chip:hover { border-color: var(--accent); color: var(--accent); } -.conns .chip b { color: var(--accent); font-weight: 600; } -.close { - position: absolute; - top: 8px; - right: 12px; - border: none; - background: none; - font-size: 26px; + background: transparent; + color: var(--ink); cursor: pointer; - color: #555; + transition: border-color .15s, background .15s; } -.close:hover { color: var(--ink); } +#start:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } -.foot { - max-width: 980px; - margin: 0 auto; - padding: 10px 24px 40px; - color: #aaa; - font-size: 11px; +@media (prefers-reduced-motion: reduce) { + #world { transition: none; } + .tile.lock { animation: none; } + #flash { transition: none; } } diff --git a/public/app.js b/public/app.js index 406b6ce..7a9f015 100644 --- a/public/app.js +++ b/public/app.js @@ -1,281 +1,184 @@ "use strict" -const $ = (sel) => document.querySelector(sel) -const stream = $("#stream") -const status = $("#status") -const modes = $("#modes") +const $ = (s) => document.querySelector(s) +const stage = $("#stage") +const world = $("#world") +const splash = $("#splash") +const flash = $("#flash") +const startBtn = $("#start") -const PHOTOS = new Map() // id -> {thumb, title, date} -const FACETS = new Map() // type -> facet[] -const COLOURS = new Map() // photo id -> [r,g,b] +const GAP = 8 +let TILE = window.matchMedia("(max-width: 700px)").matches ? 100 : 160 +const ARROW = { N: "↑", E: "→", S: "↓", W: "←" } +const DELTA = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] } -let currentMode = "label" -let revealed = 0 -const PER_BATCH = 4 +const tiles = new Map() +const coordOf = new Map() +let cur = null +let curExits = [] +let fetchSeq = 0 +let panX = 0 +let panY = 0 +let drag = null -/* ---- tiny observable-style stream ---- */ +const key = (x, y) => `${x},${y}` -const observable = (subscribe) => { - const listeners = [] - subscribe({ - next: (v) => listeners.slice().forEach((fn) => fn(v)), - error: (e) => listeners.slice().forEach((fn) => fn && fn(e)), - }) - return { - subscribe: (fn) => { - listeners.push(fn) - return () => listeners.splice(listeners.indexOf(fn), 1) - }, - } +function applyPan() { + world.style.transform = `translate(${panX}px, ${panY}px)` } -const fromEvent = (target, event) => - observable(({ next }) => target.addEventListener(event, next)) - -const debounce = (ms) => (source) => - observable(({ next }) => { - let t - return source.subscribe((v) => { - clearTimeout(t) - t = setTimeout(() => next(v), ms) - }) - }) - -const map = (fn) => (source) => - observable(({ next }) => source.subscribe((v) => next(fn(v)))) - -/* ---- dominant colour from a thumbnail ---- */ - -function colourFrom(img) { - const size = 24 - const canvas = document.createElement("canvas") - canvas.width = size - canvas.height = size - const ctx = canvas.getContext("2d") - try { - ctx.drawImage(img, 0, 0, size, size) - const { data } = ctx.getImageData(0, 0, size, size) - let r = 0, g = 0, b = 0, n = 0 - for (let i = 0; i < data.length; i += 16) { - r += data[i]; g += data[i + 1]; b += data[i + 2]; n++ - } - return [Math.round(r / n), Math.round(g / n), Math.round(b / n)] - } catch { - return [128, 128, 128] - } +function center() { + if (!cur) return + const cx = cur.x * (TILE + GAP) + TILE / 2 + const cy = cur.y * (TILE + GAP) + TILE / 2 + panX = window.innerWidth / 2 - cx + panY = window.innerHeight / 2 - cy + applyPan() } -function rgbToHue([r, g, b]) { - r /= 255; g /= 255; b /= 255 - const max = Math.max(r, g, b), min = Math.min(r, g, b) - const d = max - min - if (d === 0) return -1 - let h - if (max === r) h = ((g - b) / d) % 6 - else if (max === g) h = (b - r) / d + 2 - else h = (r - g) / d + 4 - h = Math.round(h * 60) - return h < 0 ? h + 360 : h -} - -const HUE_NAMES = [ - "red", "orange", "yellow", "yellow", "green", "green", - "green", "cyan", "blue", "blue", "violet", "magenta", "red", -] - -async function colourFor(id, src) { - if (COLOURS.has(id)) return COLOURS.get(id) - return new Promise((resolve) => { - const img = new Image() - img.crossOrigin = "anonymous" - img.onload = () => { const c = colourFrom(img); COLOURS.set(id, c); resolve(c) } - img.onerror = () => { const c = [128, 128, 128]; COLOURS.set(id, c); resolve(c) } - img.src = src - }) -} - -/* ---- rendering ---- */ - -function thumbEl(photo, { full = false } = {}) { - const a = document.createElement("a") - a.href = `#photo-${photo.id}` - a.dataset.id = photo.id +function addTile(info, x, y) { + const el = document.createElement("div") + el.className = "tile" + el.dataset.id = info.id + el.dataset.x = x + el.dataset.y = y + el.style.left = x * (TILE + GAP) + "px" + el.style.top = y * (TILE + GAP) + "px" + el.style.width = TILE + "px" + el.style.height = TILE + "px" const img = document.createElement("img") + img.src = info.thumb + img.alt = info.title || "photo" img.loading = "lazy" - img.alt = photo.title || "photo" - img.src = photo.thumb - a.appendChild(img) - const sw = document.createElement("span") - sw.className = "swatch" - a.appendChild(sw) - colourFor(photo.id, photo.thumb).then(([r, g, b]) => { - sw.style.background = `rgb(${r},${g},${b})` - }) - a.addEventListener("click", (e) => { e.preventDefault(); openLightbox(photo.id) }) - return a + el.appendChild(img) + world.appendChild(el) + requestAnimationFrame(() => el.classList.add("lock")) + tiles.set(key(x, y), { el, info }) + coordOf.set(info.id, { x, y }) + return el } -function threadEl(facet) { - const sec = document.createElement("section") - sec.className = "thread" - sec.id = `thread-${facet.type}-${facet.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}` - const head = document.createElement("div") - head.className = "thread-head" - const h = document.createElement("h2") - h.textContent = facet.name - const c = document.createElement("span") - c.className = "count" - c.textContent = `${facet.count}` - head.append(h, c) - const strip = document.createElement("div") - strip.className = "strip" - for (const id of facet.ids) { - const p = PHOTOS.get(id) - if (p) strip.appendChild(thumbEl(p)) - } - sec.append(head, strip) - return sec +function setCurrent(id, x, y) { + for (const t of tiles.values()) t.el.classList.remove("current") + cur = { id, x, y } + tiles.get(key(x, y)).el.classList.add("current") + curExits = [] + renderNav() + center() + fetchExits(id) } -function renderChunk() { - const facets = FACETS.get(currentMode) || [] - const next = facets.slice(revealed, revealed + PER_BATCH) - const frag = document.createDocumentFragment() - for (const f of next) frag.appendChild(threadEl(f)) - stream.appendChild(frag) - revealed += next.length - if (revealed >= facets.length) { - status.textContent = `${facets.length} paths shown` - sentinel.unobserve() - } else { - status.textContent = `${revealed} of ${facets.length} paths` - } +function flashText(text) { + flash.textContent = text + flash.classList.remove("show") + void flash.offsetWidth + flash.classList.add("show") + clearTimeout(flashText._t) + flashText._t = setTimeout(() => flash.classList.remove("show"), 1600) } -const sentinel = document.createElement("div") -sentinel.id = "sentinel" - -const reveal$ = map(() => renderChunk())(debounce(60)(fromEvent(window, "scroll"))) -reveal$.subscribe(() => {}) -new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) renderChunk() -}).observe(sentinel) - -/* ---- colour paths ---- */ - -const SWATCH_NAMES = [ - "red", "orange", "gold", "yellow", "lime", "green", - "teal", "cyan", "azure", "blue", "violet", "magenta", -] - -function colourPaths(ids) { - const buckets = new Map() - for (const id of ids) { - const c = COLOURS.get(id) - if (!c) continue - const hue = rgbToHue(c) - const bucket = hue < 0 ? 0 : Math.round(hue / 30) % 12 - if (!buckets.has(bucket)) buckets.set(bucket, []) - buckets.get(bucket).push(id) +function renderNav() { + if (!cur) return + const tileEl = tiles.get(key(cur.x, cur.y)).el + tileEl.querySelectorAll(".nav").forEach((n) => n.remove()) + const nav = document.createElement("div") + nav.className = "nav" + for (const ex of curExits) { + const b = document.createElement("button") + b.className = "nav-btn" + b.dataset.dir = ex.dir + b.textContent = ARROW[ex.dir] + const lab = document.createElement("span") + lab.className = "nav-label" + lab.textContent = `${ex.path.name} · ${ex.path.count}` + b.appendChild(lab) + b.addEventListener("pointerdown", (e) => e.stopPropagation()) + b.addEventListener("click", (e) => { e.stopPropagation(); move(ex.dir) }) + nav.appendChild(b) } - const out = [] - for (const [bucket, bucketIds] of buckets) { - if (bucketIds.length < 3) continue - out.push({ type: "colour", label: "Colours", name: SWATCH_NAMES[bucket], count: bucketIds.length, ids: bucketIds }) - } - return out.sort((a, b) => b.count - a.count) -} - -/* ---- lightbox ---- */ - -async function openLightbox(id) { - const box = $("#lightbox") - const resp = await fetch(`/api/photo/${id}`) - if (!resp.ok) return - const data = await resp.json() - $("#lightbox-img").src = data.photo.full - $("#lightbox-title").textContent = data.photo.title || "untitled" - $("#lightbox-date").textContent = data.photo.date - const conns = $("#lightbox-conns") - conns.innerHTML = "" - const h = document.createElement("h3") - h.textContent = "connections" - conns.appendChild(h) - for (const c of data.connections) { - const chip = document.createElement("span") - chip.className = "chip" - chip.textContent = `${c.name} · ` - const b = document.createElement("b") - b.textContent = `${c.n}` - chip.appendChild(b) - chip.addEventListener("click", () => { closeLightbox(); jumpTo(c.type, c.name) }) - conns.appendChild(chip) - } - box.hidden = false + tileEl.appendChild(nav) } -function closeLightbox() { - $("#lightbox").hidden = true +async function fetchExits(id) { + const seq = ++fetchSeq + try { + const r = await fetch(`/api/exits/${id}`) + if (!r.ok) return + const data = await r.json() + if (seq !== fetchSeq || !cur || cur.id !== id) return + curExits = data.exits || [] + renderNav() + } catch { /* ignore */ } } -function jumpTo(type, name) { - setMode(type) - const t = FACETS.get(type).find((f) => f.name === name) - if (!t) return - const el = document.getElementById(`thread-${type}-${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`) - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }) - el.firstElementChild.animate([{ opacity: 0.4 }, { opacity: 1 }], { duration: 700 }) +async function move(dir) { + const ex = curExits.find((e) => e.dir === dir) + if (!ex) return + flashText(`${ex.path.name} · ${ex.path.count}`) + const [dx, dy] = DELTA[dir] + const nx = cur.x + dx + const ny = cur.y + dy + const at = coordOf.get(ex.next.id) + if (at) { + setCurrent(ex.next.id, at.x, at.y) + } else { + addTile(ex.next, nx, ny) + setCurrent(ex.next.id, nx, ny) } } -/* ---- mode switching ---- */ - -function setMode(mode) { - if (mode === currentMode && revealed > 0) return - currentMode = mode - revealed = 0 - stream.innerHTML = "" - stream.appendChild(sentinel) - for (const b of modes.children) b.classList.toggle("on", b.dataset.mode === mode) - renderChunk() -} +stage.addEventListener("pointerdown", (e) => { + if (e.target.closest(".nav-btn")) return + drag = { x: e.clientX, y: e.clientY, px: panX, py: panY, moved: false, tile: e.target.closest(".tile") } + world.style.transition = "none" + stage.setPointerCapture(e.pointerId) +}) -function buildColourMode() { - if (FACETS.has("colour")) return - const ids = Array.from(PHOTOS.keys()) - Promise.all(ids.map((id) => colourFor(id, PHOTOS.get(id).thumb))).then(() => { - FACETS.set("colour", colourPaths(ids)) - if (currentMode === "colour") renderChunk() - }) -} +stage.addEventListener("pointermove", (e) => { + if (!drag) return + const dx = e.clientX - drag.x + const dy = e.clientY - drag.y + if (!drag.moved && Math.hypot(dx, dy) > 5) drag.moved = true + if (drag.moved) { + panX = drag.px + dx + panY = drag.py + dy + applyPan() + } +}) -modes.addEventListener("click", (e) => { - const b = e.target.closest("button") - if (!b) return - const mode = b.dataset.mode - setMode(mode) - if (mode === "colour") buildColourMode() +stage.addEventListener("pointerup", (e) => { + if (!drag) return + const wasDrag = drag.moved + const tile = drag.tile + drag = null + world.style.transition = "" + if (wasDrag || !tile) return + const x = Number(tile.dataset.x) + const y = Number(tile.dataset.y) + const info = tiles.get(key(x, y)).info + if (cur && cur.id === info.id) return + setCurrent(info.id, x, y) }) -$("#lightbox-close").addEventListener("click", closeLightbox) -$("#lightbox").addEventListener("click", (e) => { if (e.target === $("#lightbox")) closeLightbox() }) -document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeLightbox() }) +addEventListener("keydown", (e) => { + const map = { ArrowUp: "N", ArrowRight: "E", ArrowDown: "S", ArrowLeft: "W" } + const dir = map[e.key] + if (dir) { + e.preventDefault() + move(dir) + } +}) -/* ---- boot ---- */ +addEventListener("resize", center) -async function boot() { - status.textContent = "finding paths…" - const resp = await fetch("/api/paths") - if (!resp.ok) { status.textContent = "could not reach the library"; return } - const data = await resp.json() - for (const [id, p] of Object.entries(data.photos)) PHOTOS.set(Number(id), p) - for (const f of data.facets) { - if (!FACETS.has(f.type)) FACETS.set(f.type, []) - FACETS.get(f.type).push(f) +startBtn.addEventListener("click", async () => { + splash.classList.add("hide") + try { + const r = await fetch("/api/random") + const data = await r.json() + addTile(data.photo, 0, 0) + setCurrent(data.photo.id, 0, 0) + } catch { + flashText("could not reach the library") } - stream.appendChild(sentinel) - renderChunk() -} - -boot() +}) diff --git a/public/index.html b/public/index.html index e59101c..4059428 100644 --- a/public/index.html +++ b/public/index.html @@ -3,38 +3,21 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>pathways — photo viewer</title> + <meta name="color-scheme" content="dark"> + <title>pathways</title> <link rel="stylesheet" href="/app.css"> </head> <body> - <header class="mast"> + <main id="stage"> + <div id="world"></div> + <p id="hint">hover the current photo for its four paths · arrow keys walk · drag to pan</p> + </main> + <div id="splash"> <h1>pathways<span class="dot">.</span></h1> - <p class="tag">a way through the library, along the things photos share.</p> - <nav id="modes" class="modes" aria-label="path type"> - <button data-mode="label" class="on">labels</button> - <button data-mode="keyword">keywords</button> - <button data-mode="camera">cameras</button> - <button data-mode="lens">lenses</button> - <button data-mode="colour">colours</button> - </nav> - <p id="status" class="status"></p> - </header> - - <main id="stream" class="stream"></main> - - <div id="lightbox" class="lightbox" hidden> - <div class="lightbox-inner"> - <img id="lightbox-img" alt=""> - <div class="lightbox-side"> - <h2 id="lightbox-title"></h2> - <p id="lightbox-date" class="dim"></p> - <div id="lightbox-conns" class="conns"></div> - </div> - <button id="lightbox-close" class="close" aria-label="close">×</button> - </div> + <p>Every photo holds four paths — one for each direction. Walk them, and the pictures lock into a mosaic behind you.</p> + <button id="start">start</button> </div> - - <footer class="foot">pathways · built over the photoprism database · no tracking</footer> - <script src="/app.js" defer></script> + <div id="flash"></div> + <script src="/app.js"></script> </body> </html> |
