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
+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; }
}