Severity chips, SVG icons, hero artwork tile, uwu masthead

This commit is contained in:
Archibold committed 2026-11-07 18:22:52 +11:00
1 parent 24dd017905
commit 34e1b5150a
5 files changed
+142 -13

No files matched your search

+10 -3
View File
@@ -214,7 +214,7 @@ in three kinds:
| kind | what it shows | what it needs |
| --- | --- | --- |
| `notice` | pinned text, nothing fetched | `body` (≤ 5000 chars, line breaks kept) |
| `notice` | pinned text, nothing fetched | `body` (≤ 5000 chars, line breaks kept), `severity` (`info`/`heads-up`/`urgent`, default `info`) |
| `calendar` | a release window from a Sonarr-family API | `base_url`, `api_prefix` (default `/api/v3`), `days` (1-90), `date_type` (`auto`/`physical`/`digital`/`cinemas`/`release`) |
| `recent` | what just landed (`<base_url>/library/recentlyAdded`) | `base_url`, `limit` (1-50) |
@@ -228,6 +228,7 @@ in three kinds:
"icon": "",
"kind": "calendar",
"body": null,
"severity": "info",
"base_url": "http://internal-host:8080",
"headers": { "X-Api-Key": "redacted" },
"api_prefix": "/api/v3",
@@ -256,9 +257,15 @@ in three kinds:
- Artwork (Radarr posters, Plex thumbs) is relayed through
`/api/widgets/{id}/image?u=...` - the browser cannot reach the internal host,
and the relay only ever fetches from that widget's own origin.
- `severity` is notice-only: `urgent`/`heads-up`/`info` tints the card edge, the
chip, and the icon on Home. Setting it on a live widget is a validation error
(live rows have no severity to sort by).
- Home renders one merged calendar card (every `calendar` widget, sorted by
date), one card per `recent` widget, then notices. Each widget also gets its
own sidebar button for a single-card view.
date), one card per `recent` widget, then notices - urgent first, then heads-up
then info, each group in creation order. The next upcoming release that carries
artwork gets a full-width hero tile above the timeline (its relayed thumb plus
title and date); the timeline row keeps its own small thumb. Each widget also
gets its own sidebar button for a single-card view.
- Writes are admin-only when OIDC is on, reads any session - same gate as
services.
+19
View File
@@ -121,6 +121,7 @@ class ServicePatch(BaseModel):
WIDGET_KINDS = ("notice", "calendar", "recent")
DATE_TYPES = ("auto", "physical", "digital", "cinemas", "release")
SEVERITIES = ("info", "heads-up", "urgent")
class WidgetInput(BaseModel):
@@ -136,6 +137,7 @@ class WidgetInput(BaseModel):
icon: str = Field(default="", max_length=8)
kind: str = "notice"
body: Optional[str] = Field(default=None, min_length=1, max_length=5000)
severity: str = "info"
base_url: Optional[str] = Field(default=None, max_length=500)
headers: Dict[str, str] = Field(default_factory=dict)
api_prefix: str = Field(default="/api/v3", max_length=32)
@@ -173,6 +175,12 @@ class WidgetInput(BaseModel):
raise ValueError(f"date_type must be one of {DATE_TYPES}")
return value
@field_validator("severity")
def _validate_severity(cls, value: str) -> str:
if value not in SEVERITIES:
raise ValueError(f"severity must be one of {SEVERITIES}")
return value
@model_validator(mode="after")
def _validate_kind_fields(self) -> "WidgetInput":
if self.kind == "notice":
@@ -180,6 +188,8 @@ class WidgetInput(BaseModel):
raise ValueError("notice widgets need a body")
elif not self.base_url:
raise ValueError(f"{self.kind} widgets need a base_url")
elif self.severity != "info":
raise ValueError("severity only applies to notice widgets")
return self
@@ -198,6 +208,7 @@ class WidgetPatch(BaseModel):
icon: Optional[str] = Field(default=None, max_length=8)
kind: Optional[str] = None
body: Optional[str] = Field(default=None, min_length=1, max_length=5000)
severity: Optional[str] = None
base_url: Optional[str] = Field(default=None, max_length=500)
headers: Optional[Dict[str, str]] = None
api_prefix: Optional[str] = Field(default=None, max_length=32)
@@ -232,6 +243,14 @@ class WidgetPatch(BaseModel):
raise ValueError(f"date_type must be one of {DATE_TYPES}")
return value
@field_validator("severity")
def _validate_severity(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
if value not in SEVERITIES:
raise ValueError(f"severity must be one of {SEVERITIES}")
return value
ROLES = ("admin", "viewer")
+56 -6
View File
@@ -33,6 +33,7 @@ const el = {
wIcon: document.getElementById("w-icon"),
wKind: document.getElementById("w-kind"),
wBody: document.getElementById("w-body"),
wSev: document.getElementById("w-sev"),
wBase: document.getElementById("w-base"),
wHeaders: document.getElementById("w-headers"),
wPrefix: document.getElementById("w-prefix"),
@@ -133,7 +134,7 @@ function renderSidebar() {
button.className = "widget-btn" + (state.selected === widget.id ? " active" : "");
button.title = widget.name;
button.innerHTML =
`<span class="icon">${esc(widget.icon || WIDGET_ICONS[widget.kind])}</span>` +
`<span class="icon">${widgetIcon(widget)}</span>` +
`<span><span class="label">${esc(widget.name)}</span>` +
`<span class="sub">${esc(widgetSub(widget))}</span></span>`;
button.addEventListener("click", () => openWidget(widget));
@@ -141,10 +142,19 @@ function renderSidebar() {
}
}
const WIDGET_ICONS = { notice: "📌", calendar: "📅", recent: "🆕" };
// Inline SVG, not emoji: a font-less client renders emoji as tofu boxes, and
// these inherit the card's colour, so severity tints the icon for free.
const ICONS = {
notice:
'<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M12 12v8M8 20h8"/></svg>',
calendar:
'<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4M8 14h3M13 17h3"/></svg>',
recent:
'<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v10M7 12h10"/></svg>',
};
function widgetIcon(widget) {
return esc(widget.icon || WIDGET_ICONS[widget.kind]);
return widget.icon ? esc(widget.icon) : ICONS[widget.kind];
}
function widgetSub(widget) {
@@ -198,7 +208,7 @@ function renderWidgetTable() {
row.innerHTML =
`<div class="meta"><strong>${esc(widget.name)}</strong>` +
`<code>${esc(detail)}</code>` +
`<span>${esc(widget.kind)}</span></div>` +
`<span>${esc(widget.kind === "notice" && widget.severity !== "info" ? `${widget.kind} · ${widget.severity}` : widget.kind)}</span></div>` +
`<span class="status" data-status="${esc(widget.id)}">${esc(status)}</span>` +
(live
? `<button class="btn ghost" data-action="test" data-id="${esc(widget.id)}">Test</button>`
@@ -210,10 +220,14 @@ function renderWidgetTable() {
}
function noticeCard(widget) {
const severity = widget.severity || "info";
return (
`<div class="widget-card notice">` +
`<div class="widget-card notice ${severity}">` +
`<span class="icon">${widgetIcon(widget)}</span>` +
`<div class="widget-title">${esc(widget.name)}</div>` +
(severity === "info"
? ""
: `<span class="widget-sev">${esc(severity)}</span>`) +
`<div class="widget-body">${esc(widget.body || "")}</div>` +
`</div>`
);
@@ -299,21 +313,54 @@ function widgetCard(widget) {
return widget.kind === "notice" ? noticeCard(widget) : liveCard([widget], widget.name);
}
const SEVERITY_RANK = { urgent: 0, "heads-up": 1, info: 2 };
// The next release with artwork becomes one big tile above the timeline: the
// board otherwise reads as four equal text cards with no focal point.
function heroTile(widgets) {
const thumbnailed = widgets
.flatMap((widget) => (widget.data || {}).items || [])
.filter((item) => item.thumb && (item.date || "").split("-").length === 3);
if (!thumbnailed.length) return "";
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const upcoming = thumbnailed
.filter((item) => {
const parts = item.date.split("-").map(Number);
return new Date(parts[0], parts[1] - 1, parts[2]) >= today;
})
.sort((a, b) => (a.date < b.date ? -1 : 1));
const next = upcoming.length ? upcoming[0] : thumbnailed[0];
return (
`<div class="home-hero">` +
`<img class="home-hero-img" src="${esc(next.thumb)}" alt="${esc(next.label)}" loading="lazy" />` +
`<div class="home-hero-cap">` +
`<span class="home-hero-title">${esc(next.label)}</span>` +
`<span class="home-hero-sub">${esc(dayLabel(next.date))}</span>` +
`</div>` +
`</div>`
);
}
function renderHome() {
const calendars = state.widgets.filter((widget) => widget.kind === "calendar");
const recents = state.widgets.filter((widget) => widget.kind === "recent");
const notices = state.widgets.filter((widget) => widget.kind === "notice");
notices.sort((a, b) => SEVERITY_RANK[a.severity || "info"] - SEVERITY_RANK[b.severity || "info"]);
const cards = [];
if (calendars.length) cards.push(liveCard(calendars, "Upcoming"));
for (const widget of recents) cards.push(liveCard([widget], widget.name));
for (const widget of notices) cards.push(noticeCard(widget));
const banner =
`<div class="home-banner">` +
'<svg class="home-mascot" viewBox="0 0 36 34" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12v4q3.5 5 7 0v-4M21 12v4q3.5 5 7 0v-4M11 24l4 5l4-5l4 5l4-5"/></svg>' +
`<div class="home-head">` +
`<div class="home-title">Home (uwu)</div>` +
`<div class="home-sub">widgets — release calendars, what just landed, and anything pinned</div>` +
`</div>` +
`</div>`;
el.widgetPanel.innerHTML =
banner + (cards.join("") || '<p class="home-empty">no widgets yet ( 🥯🥯 ) — add one from Settings!</p>');
banner + heroTile([...calendars, ...recents]) + (cards.join("") || '<p class="home-empty">no widgets yet ( 🥯🥯 ) — add one from Settings!</p>');
}
function findService(id) {
@@ -486,6 +533,7 @@ function resetWidgetForm() {
el.wIcon.value = "";
el.wKind.value = "notice";
el.wBody.value = "";
el.wSev.value = "info";
el.wBase.value = "";
el.wHeaders.value = "";
el.wPrefix.value = "/api/v3";
@@ -501,6 +549,7 @@ function fillWidgetForm(widget) {
el.wIcon.value = widget.icon || "";
el.wKind.value = widget.kind;
el.wBody.value = widget.body || "";
el.wSev.value = widget.severity;
el.wBase.value = widget.base_url || "";
el.wHeaders.value = headersToText(widget.headers);
el.wPrefix.value = widget.api_prefix;
@@ -547,6 +596,7 @@ async function saveWidget(event) {
icon: el.wIcon.value.trim(),
kind: el.wKind.value,
body: el.wBody.value.trim() || null,
severity: el.wSev.value,
base_url: el.wBase.value.trim() || null,
headers: textToHeaders(el.wHeaders.value),
api_prefix: el.wPrefix.value.trim() || "/api/v3",
+49 -4
View File
@@ -265,16 +265,52 @@ body {
.home-banner {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 22px;
align-items: center;
gap: 14px;
padding: 14px 22px;
border-radius: 12px;
border: 1px solid var(--accent);
background: var(--panel-2);
border-top: 3px solid var(--accent);
background: linear-gradient(100deg, var(--panel-2), var(--panel));
}
.home-mascot { width: 40px; height: 34px; }
.home-head { display: flex; flex-direction: column; gap: 4px; }
.home-title { font-size: 22px; font-weight: 800; }
.home-sub { color: var(--muted); font-size: 13px; }
/* the next release with artwork: one focal tile above the timeline */
.home-hero {
position: relative;
height: 170px;
border-radius: 12px;
overflow: hidden;
margin-bottom: 14px;
border: 1px solid var(--border);
}
.home-hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.home-hero-cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 14px 14px 10px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: #fff;
display: flex;
align-items: baseline;
gap: 12px;
}
.home-hero-title { font-weight: 700; font-size: 15px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
.home-hero-sub { font-size: 12px; color: rgba(255, 255, 255, 0.85); }
.icon-svg { width: 18px; height: 18px; vertical-align: -4px; }
.widget-card {
background: var(--panel);
border: 1px solid var(--border);
@@ -286,6 +322,15 @@ body {
.widget-card .widget-title { font-weight: 700; }
.widget-card.notice .widget-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
/* severity tints the card edge, the chip, and (via currentColor) the icon */
.widget-card.notice.heads-up { border-left: 3px solid var(--warning); }
.widget-card.notice.urgent { border-left: 3px solid var(--error); }
.widget-card.notice .widget-sev { font-size: 11px; font-weight: 700; letter-spacing: 0.4px; color: var(--muted); }
.widget-card.notice.heads-up .widget-sev { color: var(--warning); }
.widget-card.notice.urgent .widget-sev { color: var(--error); }
.widget-card.notice.heads-up .icon { color: var(--warning); }
.widget-card.notice.urgent .icon { color: var(--error); }
/* live widgets: one merged timeline for calendars, own card for recents */
.widget-card.live { border-left: 3px solid var(--accent); }
.widget-card.live .widget-meta { color: var(--muted); font-size: 12px; }
+8
View File
@@ -111,6 +111,14 @@
Body (notices)
<textarea id="w-body" rows="5" placeholder="Anything the crew should know"></textarea>
</label>
<label>
Severity (notices)
<select id="w-sev">
<option value="info" selected>info</option>
<option value="heads-up">heads-up</option>
<option value="urgent">urgent</option>
</select>
</label>
<label>
Source URL (live widgets)
<input id="w-base" placeholder="http://10.6.9.204:8989" />