Add the initial mediamark app
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:
+19
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 |
@@ -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 — 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">⚓</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">🎬</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">📺</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)">←</a>
|
||||
<input class="form-control" id="search" type="search" autocomplete="off" spellcheck="false"
|
||||
placeholder="Search titles…" 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">← Back (Esc)</a>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-icon" aria-hidden="true">🎭</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">—</dd>
|
||||
<dt>Files</dt><dd id="detail-files">—</dd>
|
||||
<dt>cheeztv</dt><dd id="detail-marked">—</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>
|
||||
Reference in New Issue
Block a user