Add the initial mediamark app
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

Single Go binary serving the API and an embedded keyboard-first UI for
promoting fafflix titles into the cheeztv kids tree via hardlinks.

- internal/library: hardlink sync, idempotent re-runs, drift reporting,
  strict single-path-element name validation as the traversal guard
- internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and
  a key-brokered poster proxy
- internal/auth: server-side Authentik group enforcement on every route
- internal/server: library JSON API, art proxy, health probes, SPA
- ui: two-tile landing page, fuzzy-filtered title list, detail panel
- Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
This commit is contained in:
2026-08-29 21:27:09 +10:00
parent 3b63e8a7f1
commit c129cb99fc
26 changed files with 3456 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
// Package ui embeds the static mediamark assets.
package ui
import (
"embed"
"io/fs"
)
//go:embed static
var embedded embed.FS
// Assets returns the static asset filesystem rooted at the asset directory.
func Assets() fs.FS {
sub, err := fs.Sub(embedded, "static")
if err != nil {
panic(err)
}
return sub
}
+286
View File
@@ -0,0 +1,286 @@
/* Arrstack UI — Bootstrap-3-style layout + material-elevation polish, dressed
in a pirates-in-space / high-seas theme. Fully self-contained (no CDNs). */
:root {
--sky-deep: #070d1f;
--sea-deep: #0f2733;
--sea: #16394a;
--sea-mid: #1d4a5e;
--parchment: #f2e4c9;
--parchment-dk: #e4d0a7;
--rope: #b8894b;
--rope-dk: #8a5f2c;
--brass: #c9a25a;
--ink: #2b2015;
--danger: #a5382e;
--danger-dk: #7e281f;
--ok: #3c6e4f;
--shadow: 0 2px 6px rgba(0,0,0,.25), 0 8px 24px rgba(0,0,0,.18);
--radius: 6px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background-color: var(--sky-deep);
background-image: url("bg-space-pirate.svg");
background-size: cover;
background-position: center top;
background-attachment: fixed;
background-repeat: no-repeat;
min-height: 100vh;
}
.container { max-width: 960px; margin: 0 auto; padding: 0 16px; }
/* ---- navbar (rope-trimmed) ---- */
.navbar {
background: linear-gradient(180deg, var(--sea) 0%, var(--sea-deep) 100%);
border-bottom: 4px solid var(--rope);
box-shadow: var(--shadow);
}
.navbar-inner { display: flex; align-items: baseline; gap: 12px; padding: 14px 16px; flex-wrap: wrap; }
.brand { color: var(--parchment); font-size: 1.5rem; font-weight: 700; letter-spacing: .5px; }
.brand-mark { color: var(--brass); }
.brand-tag { color: var(--parchment-dk); font-style: italic; opacity: .85; }
.whoami { margin-left: auto; color: var(--parchment-dk); font-size: .9rem; }
/* ---- hero ---- */
.hero { padding: 32px 0 8px; }
/* Hero copy sits directly on the dark starfield, so it uses light pirate-gold /
parchment tones with a dark scrim shadow for readability over stars. */
.hero h1 { margin: 0 0 6px; font-size: 2rem; color: var(--brass); text-shadow: 0 1px 4px rgba(0,0,0,.85); }
.lead { font-size: 1.1rem; color: var(--parchment); margin: 0; text-shadow: 0 1px 3px rgba(0,0,0,.85); }
/* ---- app icon grid (square tiles) ---- */
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
margin: 24px 0;
}
.app-tile {
aspect-ratio: 1 / 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: #fff8ea;
border: 1px solid var(--parchment-dk);
border-top: 4px solid var(--sea-mid);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 16px;
text-decoration: none;
color: var(--ink);
transition: transform .12s ease, box-shadow .12s ease;
}
a.app-tile:hover { transform: translateY(-3px); box-shadow: 0 6px 14px rgba(0,0,0,.28); }
.app-tile-icon { width: 55%; max-width: 96px; height: auto; }
.app-tile-name { font-weight: 700; font-size: 1.05rem; text-transform: capitalize; }
.app-tile.locked { opacity: .55; filter: grayscale(.4); cursor: not-allowed; }
.badge-locked { font-size: .7rem; background: var(--danger); color: #fff; padding: 2px 6px; border-radius: 3px; }
/* ---- panels ---- */
.panel {
background: #fff8ea;
border: 1px solid var(--parchment-dk);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin: 24px 0 48px;
}
.panel-heading {
display: flex; align-items: center; gap: 10px;
background: linear-gradient(180deg, var(--sea-mid), var(--sea));
color: var(--parchment);
padding: 14px 20px;
border-radius: var(--radius) var(--radius) 0 0;
border-bottom: 3px solid var(--rope);
}
.panel-heading h2 { margin: 0; font-size: 1.25rem; }
.panel-icon { font-size: 1.4rem; }
.panel-body { padding: 20px; }
/* ---- forms ---- */
.form-group { margin-bottom: 16px; }
label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--sea-deep); }
.form-control {
width: 100%; padding: 9px 12px;
border: 1px solid var(--parchment-dk); border-radius: var(--radius);
background: #fffdf6; font-size: 1rem;
transition: border-color .12s ease, box-shadow .12s ease;
}
.form-control:focus { outline: none; border-color: var(--brass); box-shadow: 0 0 0 3px rgba(201,162,90,.3); }
.app-checks { display: flex; flex-wrap: wrap; gap: 14px; }
.app-checks label { font-weight: 400; display: inline-flex; align-items: center; gap: 6px; margin: 0; }
/* ---- buttons (material elevation + ripple-ish) ---- */
.btn {
display: inline-block; border: none; border-radius: var(--radius);
padding: 10px 18px; font-size: 1rem; font-weight: 600; cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,.2); transition: transform .08s ease, box-shadow .12s ease, background .12s ease;
text-decoration: none; text-align: center;
}
.btn:active { transform: translateY(1px); box-shadow: 0 1px 2px rgba(0,0,0,.2); }
.btn-primary { background: var(--rope); color: #2b1c08; }
.btn-primary:hover { background: var(--rope-dk); color: #fff; }
.btn-copy { background: var(--brass); color: var(--ink); padding: 6px 12px; }
.btn-danger { background: var(--danger); color: #fff; padding: 5px 12px; font-size: .85rem; }
.btn-danger:hover { background: var(--danger-dk); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
/* ---- token reveal ---- */
.mint-result { margin-top: 18px; padding: 16px; background: #2b2015; border-radius: var(--radius); }
.mint-result .muted { color: var(--parchment-dk); }
.token-reveal { display: flex; gap: 10px; align-items: center; }
.token-reveal code {
flex: 1; background: #12100b; color: #7ee0a1; padding: 10px 12px;
border-radius: var(--radius); word-break: break-all; font-family: "SF Mono", Menlo, Consolas, monospace;
}
/* ---- keys list ---- */
.keys-title { margin-top: 28px; border-top: 2px dashed var(--parchment-dk); padding-top: 18px; color: var(--rope-dk); }
.keys-list { display: flex; flex-direction: column; gap: 10px; }
.key-row {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: #fffdf6; border: 1px solid var(--parchment-dk); border-left: 4px solid var(--sea-mid);
border-radius: var(--radius); padding: 12px 14px;
}
.key-row.disabled { border-left-color: var(--danger); opacity: .6; }
.key-label { font-weight: 700; }
.key-meta { color: #7a6b52; font-size: .85rem; }
.key-apps { display: flex; gap: 6px; }
.chip { background: var(--sea-mid); color: var(--parchment); font-size: .72rem; padding: 2px 8px; border-radius: 10px; text-transform: capitalize; }
.key-row .spacer { margin-left: auto; }
/* ---- utility ---- */
.muted { color: #7a6b52; }
.hidden { display: none !important; }
code { font-family: "SF Mono", Menlo, Consolas, monospace; }
/* ---- toast ---- */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--sea-deep); color: var(--parchment);
padding: 12px 20px; border-radius: var(--radius); box-shadow: var(--shadow);
border-left: 4px solid var(--brass); z-index: 50;
}
.toast.err { border-left-color: var(--danger); }
/* ==========================================================================
Mediamark additions — landing tiles, sticky search, title list, detail.
Same palette and elevation language as the arrstack skin above.
========================================================================== */
a.brand { text-decoration: none; }
.view.hidden { display: none; }
kbd {
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .72rem;
background: var(--ink); color: var(--parchment); padding: 1px 6px;
border-radius: 3px; border-bottom: 2px solid #000;
}
/* ---- landing: exactly two big square tiles ---- */
.app-grid-2 { grid-template-columns: repeat(2, 1fr); max-width: 620px; gap: 24px; }
.app-tile-icon { font-size: 3.4rem; line-height: 1; width: auto; }
.app-tile-key { font-size: .78rem; color: #7a6b52; }
.app-tile.selected {
transform: translateY(-3px);
border-top-color: var(--brass);
box-shadow: 0 0 0 3px rgba(201,162,90,.55), 0 6px 14px rgba(0,0,0,.28);
}
/* ---- sticky search bar ---- */
.searchbar {
position: sticky; top: 0; z-index: 20;
background: linear-gradient(180deg, var(--sea) 0%, var(--sea-deep) 100%);
border-bottom: 4px solid var(--rope);
box-shadow: var(--shadow);
}
.searchbar-inner { display: flex; align-items: center; gap: 10px; padding: 12px 16px; }
.searchbar .form-control { flex: 1; font-size: 1.05rem; }
.search-count { color: var(--parchment-dk); font-size: .85rem; white-space: nowrap; }
.btn-back {
background: var(--sea-mid); color: var(--parchment);
padding: 8px 14px; font-size: 1.1rem; line-height: 1;
}
.btn-back:hover { background: var(--rope); color: var(--ink); }
.btn-back-block { display: inline-block; margin: 20px 0 0; }
.section-title {
color: var(--brass); text-shadow: 0 1px 4px rgba(0,0,0,.85);
margin: 20px 0 12px; font-size: 1.6rem;
}
/* ---- title list ---- */
.title-list { display: flex; flex-direction: column; gap: 8px; padding-bottom: 24px; outline: none; }
.title-row {
display: flex; align-items: center; gap: 14px;
background: #fff8ea; border: 1px solid var(--parchment-dk);
border-left: 4px solid var(--sea-mid); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 10px 14px;
text-decoration: none; color: var(--ink);
transition: transform .1s ease, box-shadow .1s ease;
}
.title-row:hover { transform: translateX(2px); }
.title-row.marked { border-left-color: var(--brass); }
.title-row.selected {
border-color: var(--brass);
box-shadow: 0 0 0 3px rgba(201,162,90,.55), var(--shadow);
}
.title-art {
width: 46px; height: 68px; flex: 0 0 46px;
border-radius: 3px; overflow: hidden; background: var(--sea-deep);
display: flex; align-items: center; justify-content: center;
}
.poster { width: 100%; height: 100%; object-fit: cover; display: block; }
.poster-placeholder { color: var(--parchment-dk); font-size: 1.4rem; font-weight: 700; }
.title-main { flex: 1; min-width: 0; }
.title-name { font-weight: 700; font-size: 1.02rem; overflow-wrap: anywhere; }
.title-name mark { background: rgba(201,162,90,.45); color: inherit; border-radius: 2px; padding: 0 1px; }
.title-sub { font-size: .82rem; }
.title-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
.badge { font-size: .68rem; padding: 2px 8px; border-radius: 10px; white-space: nowrap; font-weight: 700; }
.badge-marked { background: var(--brass); color: var(--ink); }
.badge-sync { background: var(--danger); color: #fff; }
.empty { padding: 20px 0 40px; }
/* ---- detail ---- */
.detail-body { display: flex; gap: 24px; flex-wrap: wrap; }
.detail-art { flex: 0 0 200px; }
.poster-lg {
width: 200px; height: 296px; border-radius: var(--radius);
box-shadow: var(--shadow); object-fit: cover; background: var(--sea-deep);
}
.poster-lg.poster-placeholder { display: flex; align-items: center; justify-content: center; font-size: 3rem; }
.detail-meta { flex: 1; min-width: 260px; }
.detail-facts { margin: 0 0 10px; color: var(--rope-dk); font-weight: 700; }
.detail-overview { margin: 0 0 18px; line-height: 1.5; }
.detail-stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; margin: 0 0 20px; }
.detail-stats dt { font-weight: 700; color: var(--sea-deep); }
.detail-stats dd { margin: 0; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn-lg { padding: 14px 24px; font-size: 1.05rem; }
/* ---- footer key hints ---- */
.keyhint {
padding: 14px 16px 28px; color: var(--parchment-dk);
font-size: .85rem; text-shadow: 0 1px 3px rgba(0,0,0,.85);
}
/* ---- mobile ---- */
@media (max-width: 600px) {
.container { padding: 0 10px; }
.app-grid-2 { gap: 12px; }
.app-tile-icon { font-size: 2.6rem; }
.title-row { padding: 8px 10px; gap: 10px; border-radius: 4px; }
.title-art { width: 38px; height: 56px; flex-basis: 38px; }
.title-badges { flex-direction: row; align-items: center; }
.detail-art { flex: 0 0 100%; }
.poster-lg { width: 100%; height: auto; aspect-ratio: 2 / 3; }
.detail-actions .btn { flex: 1; }
.hero h1 { font-size: 1.5rem; }
}
+422
View File
@@ -0,0 +1,422 @@
/* Mediamark SPA — keyboard-first library marking. No build step, no CDNs. */
(function () {
"use strict";
var SECTIONS = {
movies: { label: "Movies", key: "m" },
tvseries: { label: "TV Series", key: "t" }
};
var state = {
view: "home",
section: null,
titles: [],
filtered: [],
selected: 0,
detail: null,
homeTile: 0
};
var el = {};
["view-home", "view-section", "view-detail", "search", "search-count", "section-title",
"title-list", "list-empty", "detail-title", "detail-poster", "detail-noposter",
"detail-facts", "detail-overview", "detail-size", "detail-files", "detail-marked",
"detail-toggle", "detail-sync", "detail-back", "toast", "keyhint-text", "home-tiles"
].forEach(function (id) { el[id] = document.getElementById(id); });
/* ---------- fzf-ish subsequence matcher ----------
Scores a subsequence match: contiguous runs and word-boundary hits score
higher, and an earlier first match wins ties. Returns null for no match. */
function fuzzy(needle, haystack) {
if (!needle) return { score: 0, positions: [] };
var n = needle.toLowerCase(), h = haystack.toLowerCase();
var positions = [], score = 0, run = 0, hi = 0;
for (var ni = 0; ni < n.length; ni++) {
var ch = n.charAt(ni);
if (ch === " ") { run = 0; continue; }
var found = -1;
for (var j = hi; j < h.length; j++) {
if (h.charAt(j) === ch) { found = j; break; }
}
if (found < 0) return null;
positions.push(found);
score += 1;
if (found === hi && ni > 0) { run++; score += 4 + run; } else { run = 0; }
var prev = found > 0 ? h.charAt(found - 1) : "";
if (found === 0) score += 8;
else if (prev === " " || prev === "-" || prev === "." || prev === "(" || prev === ":") score += 6;
hi = found + 1;
}
score -= positions[0] * 0.1;
score -= (h.length - n.length) * 0.01;
return { score: score, positions: positions };
}
function highlight(text, positions) {
if (!positions || !positions.length) return document.createTextNode(text);
var frag = document.createDocumentFragment(), at = 0;
for (var i = 0; i < positions.length; i++) {
var p = positions[i];
if (p < at) continue;
if (p > at) frag.appendChild(document.createTextNode(text.slice(at, p)));
var m = document.createElement("mark");
m.textContent = text.charAt(p);
frag.appendChild(m);
at = p + 1;
}
if (at < text.length) frag.appendChild(document.createTextNode(text.slice(at)));
return frag;
}
/* ---------- helpers ---------- */
function humanSize(bytes) {
if (!bytes) return "0 B";
var units = ["B", "KiB", "MiB", "GiB", "TiB"], i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + " " + units[i];
}
var toastTimer = null;
function toast(msg, isErr) {
el.toast.textContent = msg;
el.toast.className = "toast" + (isErr ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.toast.className = "toast hidden"; }, 3200);
}
function api(method, path) {
return fetch(path, { method: method, headers: { Accept: "application/json" } })
.then(function (res) {
return res.json().catch(function () { return {}; }).then(function (body) {
if (!res.ok) throw new Error(body.error || ("request failed (" + res.status + ")"));
return body;
});
});
}
function displayName(t) { return (t.arr && t.arr.title) || t.name; }
/* ---------- rendering ---------- */
function show(view) {
state.view = view;
el["view-home"].classList.toggle("hidden", view !== "home");
el["view-section"].classList.toggle("hidden", view !== "section");
el["view-detail"].classList.toggle("hidden", view !== "detail");
el["keyhint-text"].textContent =
view === "home" ? "m movies · t tv series · ←/→ select · enter open"
: view === "section" ? "type to filter · ↑/↓ select · enter details · m toggle cheeztv · esc back"
: "m toggle cheeztv · esc back";
}
function applyFilter() {
var q = el.search.value.trim();
var scored = [];
for (var i = 0; i < state.titles.length; i++) {
var t = state.titles[i];
var r = fuzzy(q, displayName(t));
if (!r && q) r = fuzzy(q, t.name);
if (r) scored.push({ t: t, score: r.score, positions: q ? r.positions : [] });
}
if (q) scored.sort(function (a, b) { return b.score - a.score; });
state.filtered = scored;
if (state.selected >= scored.length) state.selected = Math.max(0, scored.length - 1);
renderList();
}
function renderList() {
el["title-list"].textContent = "";
el["list-empty"].classList.toggle("hidden", state.filtered.length > 0);
el["search-count"].textContent = state.filtered.length + " / " + state.titles.length;
state.filtered.forEach(function (row, idx) {
var t = row.t;
var a = document.createElement("a");
a.className = "title-row" + (idx === state.selected ? " selected" : "") + (t.marked ? " marked" : "");
a.href = "#/" + state.section + "/" + encodeURIComponent(t.name);
a.setAttribute("role", "option");
a.dataset.index = String(idx);
var art = document.createElement("div");
art.className = "title-art";
if (t.artUrl) {
var img = document.createElement("img");
img.src = t.artUrl;
img.alt = "";
img.loading = "lazy";
img.className = "poster";
art.appendChild(img);
} else {
art.appendChild(document.createTextNode("?"));
art.classList.add("poster-placeholder");
}
a.appendChild(art);
var main = document.createElement("div");
main.className = "title-main";
var name = document.createElement("div");
name.className = "title-name";
name.appendChild(highlight(displayName(t), row.positions));
main.appendChild(name);
var sub = document.createElement("div");
sub.className = "title-sub muted";
var bits = [];
if (t.arr && t.arr.year) bits.push(t.arr.year);
if (t.arr && t.arr.status) bits.push(t.arr.status);
bits.push(humanSize(t.sizeBytes));
sub.textContent = bits.join(" · ");
main.appendChild(sub);
a.appendChild(main);
var badges = document.createElement("div");
badges.className = "title-badges";
if (t.marked) badges.appendChild(badge("cheeztv", "badge-marked"));
if (t.needsSync) badges.appendChild(badge("needs sync", "badge-sync"));
a.appendChild(badges);
el["title-list"].appendChild(a);
});
}
function badge(text, cls) {
var s = document.createElement("span");
s.className = "badge " + cls;
s.textContent = text;
return s;
}
// Arrowing moves focus out of the search box and onto the list, so the
// single-letter shortcuts (m) are unambiguous; typing any other character
// hands focus straight back to the search box.
function moveSelection(delta) {
if (!state.filtered.length) return;
state.selected = Math.min(state.filtered.length - 1, Math.max(0, state.selected + delta));
renderList();
el["title-list"].focus({ preventScroll: true });
var sel = el["title-list"].querySelector(".title-row.selected");
if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: "nearest" });
}
function currentTitle() {
if (state.view === "detail") return state.detail;
var row = state.filtered[state.selected];
return row ? row.t : null;
}
/* ---------- data ---------- */
function loadSection(section) {
state.section = section;
el["section-title"].textContent = SECTIONS[section].label;
document.title = "Mediamark — " + SECTIONS[section].label;
el["title-list"].textContent = "";
return api("GET", "/api/library/" + section).then(function (body) {
state.titles = body.titles || [];
state.selected = 0;
applyFilter();
}).catch(function (e) {
toast(e.message, true);
state.titles = [];
applyFilter();
});
}
function mergeTitle(updated) {
for (var i = 0; i < state.titles.length; i++) {
if (state.titles[i].name === updated.name) {
["marked", "needsSync", "sizeBytes", "fileCount", "unlinkedFiles"].forEach(function (k) {
state.titles[i][k] = updated[k];
});
if (state.detail && state.detail.name === updated.name) state.detail = state.titles[i];
return state.titles[i];
}
}
return null;
}
function toggleMark(t) {
if (!t) return;
var wantMark = !t.marked;
var prev = { marked: t.marked, needsSync: t.needsSync };
t.marked = wantMark; // optimistic
t.needsSync = false;
renderList();
if (state.view === "detail") renderDetail();
api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/" + (wantMark ? "mark" : "unmark"))
.then(function (body) {
mergeTitle(body.title);
var s = body.sync || {};
toast(wantMark
? "Marked " + displayName(t) + " — " + (s.linked || 0) + " linked, " + (s.unchanged || 0) + " already there"
: "Removed " + displayName(t) + " from cheeztv");
renderList();
if (state.view === "detail") renderDetail();
})
.catch(function (e) {
t.marked = prev.marked;
t.needsSync = prev.needsSync;
renderList();
if (state.view === "detail") renderDetail();
toast(e.message, true);
});
}
function syncTitle(t) {
api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/mark")
.then(function (body) {
mergeTitle(body.title);
var s = body.sync || {};
toast("Synced " + displayName(t) + " — " + (s.linked || 0) + " new, " + (s.replaced || 0) + " replaced");
renderList();
renderDetail();
})
.catch(function (e) { toast(e.message, true); });
}
/* ---------- detail ---------- */
function renderDetail() {
var t = state.detail;
if (!t) return;
el["detail-title"].textContent = displayName(t);
el["detail-back"].href = "#/" + state.section;
var facts = [];
if (t.arr && t.arr.year) facts.push(t.arr.year);
if (t.arr && t.arr.status) facts.push(t.arr.status);
facts.push(SECTIONS[state.section].label);
el["detail-facts"].textContent = facts.join(" · ");
el["detail-overview"].textContent = (t.arr && t.arr.overview) || "No overview available for this title.";
el["detail-size"].textContent = humanSize(t.sizeBytes);
el["detail-files"].textContent = String(t.fileCount);
el["detail-marked"].textContent = t.marked
? (t.needsSync ? "marked (" + t.unlinkedFiles + " files unsynced)" : "marked")
: "not marked";
if (t.artUrl) {
el["detail-poster"].src = t.artUrl;
el["detail-poster"].classList.remove("hidden");
el["detail-noposter"].classList.add("hidden");
} else {
el["detail-poster"].classList.add("hidden");
el["detail-noposter"].classList.remove("hidden");
}
el["detail-toggle"].textContent = t.marked ? "Remove from cheeztv" : "Add to cheeztv";
el["detail-toggle"].className = "btn " + (t.marked ? "btn-danger btn-lg" : "btn-primary btn-lg");
el["detail-sync"].classList.toggle("hidden", !t.needsSync);
}
function openDetail(section, name) {
var go = state.section === section && state.titles.length
? Promise.resolve()
: loadSection(section);
return go.then(function () {
var found = null;
for (var i = 0; i < state.titles.length; i++) {
if (state.titles[i].name === name) { found = state.titles[i]; break; }
}
if (!found) { toast("Title not found", true); location.hash = "#/" + section; return; }
state.detail = found;
show("detail");
renderDetail();
});
}
/* ---------- routing ---------- */
function route() {
var parts = (location.hash || "#/").replace(/^#\/?/, "").split("/");
var section = parts[0];
if (!SECTIONS[section]) {
show("home");
document.title = "Mediamark";
focusTile();
return;
}
if (parts.length > 1 && parts[1]) {
openDetail(section, decodeURIComponent(parts[1]));
return;
}
show("section");
var reload = state.section === section && state.titles.length
? Promise.resolve(applyFilter())
: loadSection(section);
reload.then(function () { el.search.focus(); el.search.select(); });
}
function tiles() { return Array.prototype.slice.call(el["home-tiles"].querySelectorAll(".app-tile")); }
function focusTile() {
var ts = tiles();
ts.forEach(function (t, i) { t.classList.toggle("selected", i === state.homeTile); });
}
/* ---------- keyboard ---------- */
document.addEventListener("keydown", function (ev) {
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
var typingInSearch = ev.target === el.search;
if (state.view === "home") {
var ts = tiles();
if (ev.key === "m") { location.hash = "#/movies"; ev.preventDefault(); return; }
if (ev.key === "t") { location.hash = "#/tvseries"; ev.preventDefault(); return; }
if (ev.key === "ArrowRight" || ev.key === "ArrowDown") {
state.homeTile = Math.min(ts.length - 1, state.homeTile + 1); focusTile(); ev.preventDefault(); return;
}
if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") {
state.homeTile = Math.max(0, state.homeTile - 1); focusTile(); ev.preventDefault(); return;
}
if (ev.key === "Enter") { ts[state.homeTile].click(); ev.preventDefault(); }
return;
}
if (ev.key === "Escape") {
if (state.view === "detail") { location.hash = "#/" + state.section; }
else if (typingInSearch && el.search.value) { el.search.value = ""; applyFilter(); }
else { location.hash = "#/"; }
ev.preventDefault();
return;
}
if (state.view === "detail") {
if (ev.key === "m") { toggleMark(state.detail); ev.preventDefault(); }
return;
}
// Section view.
if (ev.key === "ArrowDown") { moveSelection(1); ev.preventDefault(); return; }
if (ev.key === "ArrowUp") { moveSelection(-1); ev.preventDefault(); return; }
if (ev.key === "Enter") {
var t = currentTitle();
if (t) location.hash = "#/" + state.section + "/" + encodeURIComponent(t.name);
ev.preventDefault();
return;
}
if (ev.key === "m" && !typingInSearch) { toggleMark(currentTitle()); ev.preventDefault(); return; }
if (!typingInSearch && ev.key.length === 1) {
el.search.focus();
el.search.value += ev.key;
state.selected = 0;
applyFilter();
ev.preventDefault();
}
});
el.search.addEventListener("input", function () { state.selected = 0; applyFilter(); });
el["title-list"].addEventListener("click", function (ev) {
var row = ev.target.closest ? ev.target.closest(".title-row") : null;
if (row) state.selected = Number(row.dataset.index);
});
el["home-tiles"].addEventListener("mouseover", function (ev) {
var tile = ev.target.closest ? ev.target.closest(".app-tile") : null;
if (!tile) return;
state.homeTile = tiles().indexOf(tile);
focusTile();
});
el["detail-toggle"].addEventListener("click", function () { toggleMark(state.detail); });
el["detail-sync"].addEventListener("click", function () { syncTitle(state.detail); });
window.addEventListener("hashchange", route);
route();
})();
+223
View File
@@ -0,0 +1,223 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 960" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Cartoon pirate ship sailing a cosmic sea through outer space">
<defs>
<radialGradient id="sky" cx="50%" cy="40%" r="80%">
<stop offset="0%" stop-color="#12213f"/>
<stop offset="55%" stop-color="#0c1730"/>
<stop offset="100%" stop-color="#070d1f"/>
</radialGradient>
<linearGradient id="wave" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3aa6d6"/>
<stop offset="100%" stop-color="#2a3f8a"/>
</linearGradient>
<radialGradient id="ring" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#9a6bd6"/>
<stop offset="100%" stop-color="#6a3fae"/>
</radialGradient>
<radialGradient id="galaxy" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#e9d6ff"/>
<stop offset="40%" stop-color="#9a72d8"/>
<stop offset="100%" stop-color="#0c1730" stop-opacity="0"/>
</radialGradient>
<radialGradient id="moon" cx="38%" cy="35%" r="70%">
<stop offset="0%" stop-color="#f2a24a"/>
<stop offset="100%" stop-color="#c56a1f"/>
</radialGradient>
<radialGradient id="teal" cx="38%" cy="35%" r="70%">
<stop offset="0%" stop-color="#5fd6c0"/>
<stop offset="100%" stop-color="#2a9c86"/>
</radialGradient>
<linearGradient id="comet" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ff8a2a" stop-opacity="0"/>
<stop offset="100%" stop-color="#ffd24a"/>
</linearGradient>
<linearGradient id="hull" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#a9743a"/>
<stop offset="100%" stop-color="#6e451d"/>
</linearGradient>
</defs>
<!-- space backdrop -->
<rect width="1280" height="960" fill="url(#sky)"/>
<!-- stars + sparkles -->
<g fill="#ffffff">
<circle cx="90" cy="80" r="2.4"/><circle cx="180" cy="150" r="1.6"/><circle cx="260" cy="60" r="2"/>
<circle cx="360" cy="120" r="1.4"/><circle cx="470" cy="70" r="2.2"/><circle cx="560" cy="140" r="1.5"/>
<circle cx="700" cy="90" r="1.8"/><circle cx="820" cy="150" r="2"/><circle cx="930" cy="70" r="1.5"/>
<circle cx="1040" cy="130" r="2.2"/><circle cx="1150" cy="80" r="1.6"/><circle cx="1220" cy="180" r="2"/>
<circle cx="60" cy="300" r="1.7"/><circle cx="150" cy="420" r="2.1"/><circle cx="40" cy="520" r="1.5"/>
<circle cx="110" cy="640" r="2"/><circle cx="1230" cy="360" r="1.8"/><circle cx="1180" cy="470" r="2.2"/>
<circle cx="1250" cy="600" r="1.6"/><circle cx="1200" cy="720" r="2"/><circle cx="640" cy="40" r="1.4"/>
<circle cx="380" cy="300" r="1.5"/><circle cx="900" cy="320" r="1.6"/><circle cx="1080" cy="260" r="1.5"/>
</g>
<g fill="#ffd24a">
<path d="M240 240 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4z"/>
<path d="M1000 500 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4z"/>
<path d="M470 470 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z"/>
<path d="M760 250 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z"/>
</g>
<!-- purple ringed planet (top-left) -->
<g transform="translate(150,180)">
<ellipse cx="0" cy="0" rx="120" ry="34" fill="none" stroke="#d7a24a" stroke-width="10" transform="rotate(-20)"/>
<circle cx="0" cy="0" r="62" fill="url(#ring)" stroke="#0a1226" stroke-width="4"/>
<ellipse cx="0" cy="0" rx="120" ry="34" fill="none" stroke="#d7a24a" stroke-width="10" transform="rotate(-20)" stroke-dasharray="150 400" stroke-dashoffset="150"/>
</g>
<!-- spiral galaxy (top-right) -->
<g transform="translate(1080,190) rotate(25)">
<ellipse cx="0" cy="0" rx="130" ry="70" fill="url(#galaxy)"/>
<path d="M0 0 Q60 -30 90 20 Q40 40 0 0" fill="#c9a8ef" opacity="0.7"/>
<path d="M0 0 Q-60 30 -90 -20 Q-40 -40 0 0" fill="#c9a8ef" opacity="0.7"/>
<circle cx="0" cy="0" r="10" fill="#ffffff"/>
</g>
<!-- teal planet (right) -->
<circle cx="1180" cy="360" r="46" fill="url(#teal)" stroke="#0a1226" stroke-width="4"/>
<ellipse cx="1165" cy="345" rx="16" ry="8" fill="#7fe6d2" opacity="0.6"/>
<!-- orange cratered moon (bottom-right) -->
<g transform="translate(1130,800)">
<circle r="90" fill="url(#moon)" stroke="#0a1226" stroke-width="5"/>
<circle cx="-30" cy="-20" r="16" fill="#a85417" opacity="0.6"/>
<circle cx="20" cy="10" r="22" fill="#a85417" opacity="0.5"/>
<circle cx="-10" cy="40" r="12" fill="#a85417" opacity="0.6"/>
<circle cx="40" cy="-30" r="9" fill="#a85417" opacity="0.6"/>
</g>
<!-- blue asteroid/planet (bottom-left) -->
<circle cx="90" cy="770" r="70" fill="#3f6fae" stroke="#0a1226" stroke-width="5"/>
<circle cx="65" cy="745" r="14" fill="#5f8fce" opacity="0.6"/>
<circle cx="110" cy="800" r="10" fill="#2f579a" opacity="0.6"/>
<!-- flaming comet (left) -->
<g transform="translate(210,360)">
<path d="M0 0 L-150 -40 L-150 40 Z" fill="url(#comet)"/>
<circle r="22" fill="#ffce54" stroke="#e07b1f" stroke-width="4"/>
<path d="M-6 -10 q10 -14 20 0 q10 -12 16 4 q-8 8 -20 6 q-14 4 -16 -10z" fill="#ff7a2a"/>
</g>
<!-- cartoon rocket (right, angled up) -->
<g transform="translate(1000,430) rotate(-30)">
<path d="M0 -46 q22 22 22 60 h-44 q0 -38 22 -60z" fill="#eef2f7" stroke="#0a1226" stroke-width="4"/>
<circle cx="0" cy="0" r="10" fill="#7fd0ff" stroke="#0a1226" stroke-width="3"/>
<path d="M-22 14 l-18 22 18 -4z" fill="#d64a3a" stroke="#0a1226" stroke-width="3"/>
<path d="M22 14 l18 22 -18 -4z" fill="#d64a3a" stroke="#0a1226" stroke-width="3"/>
<path d="M-10 20 q10 26 20 0z" fill="#ff9a2a"/>
<path d="M-6 24 q6 16 12 0z" fill="#ffd24a"/>
</g>
<!-- scattered asteroids -->
<g fill="#8a8f9c" stroke="#0a1226" stroke-width="3">
<path d="M330 640 q-18 -10 -6 -26 q16 -12 30 -2 q16 8 8 26 q-10 16 -32 2z"/>
<path d="M980 700 q-14 -8 -4 -22 q12 -10 24 -2 q12 6 6 22 q-8 12 -26 2z"/>
<path d="M640 820 q-16 -8 -6 -24 q14 -10 28 -2 q14 8 6 24 q-10 14 -28 2z"/>
</g>
<!-- ===== cosmic sea wave ===== -->
<path d="M0 720
q120 -60 240 -20 q120 40 240 0 q120 -40 240 0 q120 40 240 -10 q120 -50 240 0
q40 20 80 10 L1280 960 L0 960 Z"
fill="url(#wave)" stroke="#1a2c66" stroke-width="4"/>
<g fill="#bfe9ff" opacity="0.85">
<path d="M120 720 q30 -26 60 0 q-30 18 -60 0z"/>
<path d="M420 706 q28 -24 56 0 q-28 18 -56 0z"/>
<path d="M760 712 q30 -26 60 0 q-30 18 -60 0z"/>
<path d="M1040 700 q28 -24 56 0 q-28 18 -56 0z"/>
</g>
<!-- foam swirl -->
<path d="M0 760 q160 40 320 10 q160 -30 320 6 q160 36 320 0 q160 -36 320 8" fill="none" stroke="#dff3ff" stroke-width="6" stroke-linecap="round" opacity="0.8"/>
<!-- ===== pirate ship ===== -->
<g transform="translate(640,560)">
<!-- main mast + black skull sail -->
<rect x="-6" y="-260" width="12" height="230" fill="#7a5228" stroke="#0a1226" stroke-width="3"/>
<!-- pennant flag -->
<path d="M6 -258 l70 16 -70 16z" fill="#c0392b" stroke="#0a1226" stroke-width="3"/>
<!-- top small skull flag -->
<g transform="translate(0,-250)">
<rect x="-2" y="-8" width="4" height="10" fill="#7a5228"/>
</g>
<!-- big black sail -->
<path d="M-150 -230 q150 30 300 0 q-30 130 0 200 q-150 30 -300 0 q30 -110 0 -200z"
fill="#151515" stroke="#0a1226" stroke-width="4"/>
<!-- skull and crossbones -->
<g transform="translate(0,-120)" fill="#f5f5f5">
<path d="M-60 0 q60 -14 120 0 M-60 20 q60 14 120 0" stroke="#f5f5f5" stroke-width="12" fill="none" stroke-linecap="round"/>
<ellipse cx="0" cy="-8" rx="44" ry="40"/>
<path d="M-30 24 q30 18 60 0 l-6 16 -12 -6 -6 10 -6 -10 -12 6z"/>
<circle cx="-16" cy="-8" r="12" fill="#151515"/>
<circle cx="16" cy="-8" r="12" fill="#151515"/>
<path d="M-4 6 l4 -10 4 10z" fill="#151515"/>
</g>
<!-- hull -->
<path d="M-200 20 q0 90 200 90 q200 0 200 -90 q-30 26 -200 26 q-170 0 -200 -26z"
fill="url(#hull)" stroke="#0a1226" stroke-width="5"/>
<!-- deck trim -->
<rect x="-200" y="8" width="400" height="18" rx="8" fill="#c69a5a" stroke="#0a1226" stroke-width="4"/>
<!-- portholes -->
<g fill="#3a2410" stroke="#0a1226" stroke-width="3">
<circle cx="-120" cy="66" r="12"/><circle cx="-60" cy="76" r="12"/>
<circle cx="0" cy="80" r="12"/><circle cx="60" cy="76" r="12"/><circle cx="120" cy="66" r="12"/>
</g>
<!-- crow's nest with telescope pirate -->
<g transform="translate(150,-190)">
<path d="M-22 0 h44 l-6 26 h-32z" fill="#8a5f2c" stroke="#0a1226" stroke-width="3"/>
<circle cx="0" cy="-10" r="12" fill="#f0c39a" stroke="#0a1226" stroke-width="2"/>
<path d="M-12 -14 q12 -10 24 0 l-2 -10 -22 0z" fill="#c0392b"/>
<rect x="6" y="-16" width="26" height="6" rx="3" fill="#3a2410" transform="rotate(-20 6 -16)" stroke="#0a1226" stroke-width="2"/>
</g>
<!-- captain (center, cutlass) -->
<g transform="translate(0,-30)">
<path d="M-18 0 q18 -8 36 0 l-4 34 h-28z" fill="#c0392b" stroke="#0a1226" stroke-width="3"/>
<circle cx="0" cy="-16" r="17" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/>
<path d="M-20 -22 q20 -18 40 0 q-6 -14 -20 -14 q-14 0 -20 14z" fill="#1b1b1b"/>
<path d="M-8 -20 h6" stroke="#1b1b1b" stroke-width="3"/>
<path d="M4 -20 h6" stroke="#1b1b1b" stroke-width="3"/>
<path d="M-4 -8 q4 4 8 0" stroke="#7a3b1b" stroke-width="3" fill="none"/>
<!-- cutlass -->
<path d="M18 -6 q40 -30 60 -50" stroke="#d9dde3" stroke-width="6" fill="none" stroke-linecap="round"/>
<rect x="14" y="-10" width="10" height="14" rx="3" fill="#c9a25a" stroke="#0a1226" stroke-width="2"/>
</g>
<!-- pirate with telescope (left, on deck) -->
<g transform="translate(-110,-24)">
<path d="M-16 0 q16 -6 32 0 l-4 26 h-24z" fill="#2f6fae" stroke="#0a1226" stroke-width="3"/>
<circle cx="0" cy="-14" r="14" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/>
<path d="M-16 -18 q16 -8 30 -2" stroke="#c0392b" stroke-width="8" fill="none" stroke-linecap="round"/>
<rect x="8" y="-18" width="30" height="7" rx="3" fill="#3a2410" transform="rotate(-15 8 -18)" stroke="#0a1226" stroke-width="2"/>
</g>
<!-- third pirate (right) -->
<g transform="translate(80,-22)">
<path d="M-14 0 q14 -6 28 0 l-4 24 h-20z" fill="#6a4aa0" stroke="#0a1226" stroke-width="3"/>
<circle cx="0" cy="-14" r="13" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/>
<path d="M-14 -18 q14 -8 28 0 l-2 -9 -24 0z" fill="#c0392b"/>
</g>
<!-- parrot on the rail -->
<g transform="translate(120,-58)">
<ellipse cx="0" cy="0" rx="12" ry="16" fill="#2ecc71" stroke="#0a1226" stroke-width="3"/>
<circle cx="0" cy="-14" r="8" fill="#2ecc71" stroke="#0a1226" stroke-width="3"/>
<path d="M6 -16 l12 4 -12 4z" fill="#f1c40f" stroke="#0a1226" stroke-width="2"/>
<circle cx="1" cy="-16" r="2" fill="#0a1226"/>
<path d="M-6 4 q-14 6 -18 20 q10 -6 20 -12z" fill="#e74c3c" stroke="#0a1226" stroke-width="2"/>
<path d="M-4 -2 q-16 4 -20 16 q10 -4 20 -8z" fill="#f39c12" stroke="#0a1226" stroke-width="2"/>
</g>
</g>
<!-- goofy fish/creature in the water -->
<g transform="translate(830,700)">
<ellipse cx="0" cy="0" rx="46" ry="30" fill="#e8b84a" stroke="#0a1226" stroke-width="4"/>
<path d="M40 0 l34 -22 v44z" fill="#e8b84a" stroke="#0a1226" stroke-width="4"/>
<circle cx="-18" cy="-8" r="12" fill="#ffffff" stroke="#0a1226" stroke-width="3"/>
<circle cx="6" cy="-8" r="12" fill="#ffffff" stroke="#0a1226" stroke-width="3"/>
<circle cx="-16" cy="-6" r="5" fill="#0a1226"/>
<circle cx="8" cy="-6" r="5" fill="#0a1226"/>
<path d="M-30 14 q18 16 44 0" stroke="#0a1226" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M-2 14 q6 22 14 8z" fill="#c0392b" stroke="#0a1226" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

+92
View File
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mediamark &mdash; Chart the Kids' Course</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8F%B4%3C/text%3E%3C/svg%3E">
</head>
<body>
<nav class="navbar">
<div class="container navbar-inner">
<a class="brand" href="#/"><span class="brand-mark">&#9875;</span> Mediamark</a>
<span class="brand-tag">stow the wholesome loot in cheeztv</span>
</div>
</nav>
<!-- Landing: two big square tiles -->
<div class="container view" id="view-home">
<header class="hero">
<h1>Which hold shall we raid?</h1>
<p class="lead">Pick a section, then mark the titles fit for the wee crew.</p>
</header>
<section class="app-grid app-grid-2" id="home-tiles" aria-label="sections">
<a class="app-tile" href="#/movies" data-section="movies" data-key="m">
<span class="app-tile-icon" aria-hidden="true">&#127916;</span>
<span class="app-tile-name">Movies</span>
<span class="app-tile-key">press <kbd>m</kbd></span>
</a>
<a class="app-tile" href="#/tvseries" data-section="tvseries" data-key="t">
<span class="app-tile-icon" aria-hidden="true">&#128250;</span>
<span class="app-tile-name">TV Series</span>
<span class="app-tile-key">press <kbd>t</kbd></span>
</a>
</section>
</div>
<!-- Section: search + filtered list -->
<div class="view hidden" id="view-section">
<div class="searchbar">
<div class="container searchbar-inner">
<a class="btn btn-back" href="#/" title="Back (Esc)">&#8592;</a>
<input class="form-control" id="search" type="search" autocomplete="off" spellcheck="false"
placeholder="Search titles&hellip;" aria-label="Search titles">
<span class="search-count muted" id="search-count"></span>
</div>
</div>
<div class="container">
<h2 class="section-title" id="section-title">Movies</h2>
<div class="title-list" id="title-list" role="listbox" aria-label="titles" tabindex="-1"></div>
<p class="muted empty hidden" id="list-empty">No titles match that search.</p>
</div>
</div>
<!-- Detail panel -->
<div class="container view hidden" id="view-detail">
<a class="btn btn-back btn-back-block" href="#" id="detail-back">&#8592; Back (Esc)</a>
<section class="panel">
<div class="panel-heading">
<span class="panel-icon" aria-hidden="true">&#127917;</span>
<h2 id="detail-title">Title</h2>
</div>
<div class="panel-body detail-body">
<div class="detail-art">
<img id="detail-poster" alt="" class="poster poster-lg hidden">
<div id="detail-noposter" class="poster poster-lg poster-placeholder">?</div>
</div>
<div class="detail-meta">
<p class="detail-facts" id="detail-facts"></p>
<p id="detail-overview" class="detail-overview"></p>
<dl class="detail-stats">
<dt>On disk</dt><dd id="detail-size">&mdash;</dd>
<dt>Files</dt><dd id="detail-files">&mdash;</dd>
<dt>cheeztv</dt><dd id="detail-marked">&mdash;</dd>
</dl>
<div class="detail-actions">
<button class="btn btn-primary" id="detail-toggle">Add to cheeztv</button>
<button class="btn btn-copy hidden" id="detail-sync">Sync new files</button>
</div>
</div>
</div>
</section>
</div>
<footer class="container keyhint">
<span id="keyhint-text"></span>
</footer>
<div id="toast" class="toast hidden"></div>
<script src="app.js"></script>
</body>
</html>