Severity chips, SVG icons, hero artwork tile, uwu masthead
This commit is contained in:
1 parent
24dd017905
commit
34e1b5150a
5 files changed
+142
-13
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user