diff --git a/README.md b/README.md index edcf38a..399e8f4 100644 --- a/README.md +++ b/README.md @@ -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 (`/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. diff --git a/src/uwudash_mk3/models.py b/src/uwudash_mk3/models.py index 8675608..062e9f3 100644 --- a/src/uwudash_mk3/models.py +++ b/src/uwudash_mk3/models.py @@ -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") diff --git a/src/uwudash_mk3/static/app.js b/src/uwudash_mk3/static/app.js index c7832c4..08bf03e 100644 --- a/src/uwudash_mk3/static/app.js +++ b/src/uwudash_mk3/static/app.js @@ -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 = - `${esc(widget.icon || WIDGET_ICONS[widget.kind])}` + + `${widgetIcon(widget)}` + `${esc(widget.name)}` + `${esc(widgetSub(widget))}`; 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: + '', + calendar: + '', + recent: + '', +}; 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 = `
${esc(widget.name)}` + `${esc(detail)}` + - `${esc(widget.kind)}
` + + `${esc(widget.kind === "notice" && widget.severity !== "info" ? `${widget.kind} ยท ${widget.severity}` : widget.kind)}` + `${esc(status)}` + (live ? `` @@ -210,10 +220,14 @@ function renderWidgetTable() { } function noticeCard(widget) { + const severity = widget.severity || "info"; return ( - `
` + + `
` + `${widgetIcon(widget)}` + `
${esc(widget.name)}
` + + (severity === "info" + ? "" + : `${esc(severity)}`) + `
${esc(widget.body || "")}
` + `
` ); @@ -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 ( + `
` + + `${esc(next.label)}` + + `
` + + `${esc(next.label)}` + + `${esc(dayLabel(next.date))}` + + `
` + + `
` + ); +} + 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 = `
` + + '' + + `
` + `
Home (uwu)
` + `
widgets โ€” release calendars, what just landed, and anything pinned
` + + `
` + `
`; el.widgetPanel.innerHTML = - banner + (cards.join("") || '

no widgets yet ( ๐Ÿฅฏ๐Ÿฅฏ ) โ€” add one from Settings!

'); + banner + heroTile([...calendars, ...recents]) + (cards.join("") || '

no widgets yet ( ๐Ÿฅฏ๐Ÿฅฏ ) โ€” add one from Settings!

'); } 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", diff --git a/src/uwudash_mk3/static/styles.css b/src/uwudash_mk3/static/styles.css index 200d66a..db3978f 100644 --- a/src/uwudash_mk3/static/styles.css +++ b/src/uwudash_mk3/static/styles.css @@ -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; } diff --git a/src/uwudash_mk3/templates/index.html b/src/uwudash_mk3/templates/index.html index dc9e046..21ee8c2 100644 --- a/src/uwudash_mk3/templates/index.html +++ b/src/uwudash_mk3/templates/index.html @@ -111,6 +111,14 @@ Body (notices) +