Initial release: arr-calendar widget with partial-services support
- Self-contained YML: all CSS inlined (no global.css edit needed) - Vanilla JS client with MutationObserver bootstrap - Slide+fade month navigation via rAF (works in this Dynacat build) - Popover portaled to <body> to escape backdrop-filter containing blocks - Per-service enabled flag, unconfigured services silently skipped - Status <ul> exposes per-service configured/ok flags for client hints
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Optional docs (uncomment to ignore the placeholder screenshot if you don't have one)
|
||||
# docs/screenshot.png
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 David Álvarez
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,377 @@
|
||||
# arr-releases
|
||||
|
||||
A Dynacat ([Panonim/dynacat](https://github.com/Panonim/dynacat)) calendar widget
|
||||
that aggregates upcoming releases from [Sonarr](https://sonarr.tv),
|
||||
[Radarr](https://radarr.video) and [Lidarr](https://lidarr.audio).
|
||||
|
||||
- Always renders exactly 6 weeks (42 cells) with prev/next month spillover
|
||||
- One coloured dot per service on each day that has a release
|
||||
- Click any day with a dot to open a popover with full release details and cover art
|
||||
- Smooth slide+fade animation on month navigation
|
||||
- **Works with any subset of the three services configured** — set `SONARR_KEY`
|
||||
only and the widget will silently skip Radarr and Lidarr
|
||||
|
||||
 *(add a screenshot if you have
|
||||
one — see [How to add a screenshot](#how-to-add-a-screenshot))*
|
||||
|
||||
## Quick install (TL;DR)
|
||||
|
||||
```bash
|
||||
# 1. Copy the widget definition
|
||||
cp arr-calendar.yml /appdata/dynacat/config/
|
||||
|
||||
# 2. Copy the client JS
|
||||
cp arr-calendar.js /appdata/dynacat/assets/
|
||||
|
||||
# 3. Add this line to /appdata/dynacat/config/dynacat.yml under document.head:
|
||||
# <script src="/assets/arr-calendar.js" defer></script>
|
||||
|
||||
# 4. Restart the Dynacat container (the Go template cache is in-memory; YML
|
||||
# edits do NOT recompile until the container restarts)
|
||||
docker restart dynacat
|
||||
```
|
||||
|
||||
Then `$include: arr-calendar.yml` in any page column and set at least one
|
||||
of `SONARR_KEY`, `RADARR_KEY`, `LIDARR_KEY` as a container env var.
|
||||
|
||||
## What the widget does
|
||||
|
||||
Each cell of the calendar corresponds to one day. On a day that has at least
|
||||
one release, the widget shows a small coloured dot:
|
||||
|
||||
- 🔵 Blue dot — Sonarr episode
|
||||
- 🟢 Green dot — Radarr movie
|
||||
- 🟣 Purple dot — Lidarr album
|
||||
|
||||
Multiple services on the same day produce a row of dots. Click the day to
|
||||
open a popover listing every release on that date, with cover art, the
|
||||
release type (Release / Digital / In Cinemas / Physical for Radarr;
|
||||
`S##E## — episode title` for Sonarr; artist name for Lidarr) and a link
|
||||
back to the *arr web UI.
|
||||
|
||||
## Full install
|
||||
|
||||
### 1. Environment variables
|
||||
|
||||
The widget reads three API keys from the container environment. **Set at
|
||||
least one** — all three are optional:
|
||||
|
||||
| Env var | Purpose | Required? |
|
||||
| ------------- | ------------------------------ | --------- |
|
||||
| `SONARR_KEY` | Sonarr API key | optional |
|
||||
| `RADARR_KEY` | Radarr API key | optional |
|
||||
| `LIDARR_KEY` | Lidarr API key | optional |
|
||||
| `BASE_HOST` | The shared part of the *arr URLs (e.g. `t3du.com`). The widget builds Sonarr/Radarr/Lidarr URLs as `https://<service>.${BASE_HOST}`. | required |
|
||||
|
||||
Get an API key from each *arr under **Settings → General → API Key**.
|
||||
|
||||
If you set the env var on the Dynacat container itself, no per-service
|
||||
configuration is needed — defaults to
|
||||
`https://sonarr.${BASE_HOST}`, `https://radarr.${BASE_HOST}`,
|
||||
`https://lidarr.${BASE_HOST}`. To use a different URL for a service, set
|
||||
the corresponding `*-url` option in your `$include` of `arr-calendar.yml`
|
||||
(see [Customising URLs](#customising-urls) below).
|
||||
|
||||
### 2. File layout
|
||||
|
||||
```
|
||||
/appdata/dynacat/
|
||||
config/
|
||||
dynacat.yml # main config (edit to add the script tag)
|
||||
arr-calendar.yml # <-- this repo, copy here
|
||||
assets/
|
||||
arr-calendar.js # <-- this repo, copy here
|
||||
global.css # (untouched; widget CSS is inlined in the YML)
|
||||
```
|
||||
|
||||
### 3. Edit `dynacat.yml`
|
||||
|
||||
Add the script tag under `document.head`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
assets-path: /app/assets
|
||||
|
||||
document:
|
||||
head: |
|
||||
<script src="/assets/arr-calendar.js" defer></script>
|
||||
```
|
||||
|
||||
Then `$include` the widget in a page column (any column size — it adapts):
|
||||
|
||||
```yaml
|
||||
pages:
|
||||
- name: Home
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- $include: arr-calendar.yml
|
||||
```
|
||||
|
||||
### 4. Restart the container
|
||||
|
||||
**YML edits do not recompile the Go template on the fly.** Glance/Dynacat
|
||||
parses each `custom-api` template once on first render and caches the
|
||||
compiled `*template.Template` in memory. File mtime updates, `touch` and
|
||||
edits to the file do not invalidate the cache. After every edit to
|
||||
`arr-calendar.yml`, restart the container:
|
||||
|
||||
```bash
|
||||
docker restart dynacat
|
||||
```
|
||||
|
||||
(or via your container manager — Dockhand, Unraid UI, etc.). The error
|
||||
message you'll get if you forget is the dreaded
|
||||
`Config has errors: custom-api widget: parsing template: ...` — see
|
||||
[Troubleshooting](#troubleshooting) below.
|
||||
|
||||
### 5. Hard-refresh the browser
|
||||
|
||||
`Ctrl+Shift+R` (or `Cmd+Shift+R` on macOS) to bypass the browser cache.
|
||||
|
||||
## Customising
|
||||
|
||||
All options live in the top of `arr-calendar.yml` under `options:`:
|
||||
|
||||
```yaml
|
||||
- type: custom-api
|
||||
options:
|
||||
sonarr-url: https://sonarr.example.com
|
||||
sonarr-key: ${SONARR_KEY}
|
||||
radarr-url: https://radarr.example.com
|
||||
radarr-key: ${RADARR_KEY}
|
||||
lidarr-url: https://lidarr.example.com
|
||||
lidarr-key: ${LIDARR_KEY}
|
||||
exclude-physical: true
|
||||
past-days: 90
|
||||
future-days: 90
|
||||
```
|
||||
|
||||
| Option | Default | Notes |
|
||||
| ---------------- | ------------------- | ----- |
|
||||
| `sonarr-url` | `https://sonarr.${BASE_HOST}` | Override if your Sonarr is on a different host |
|
||||
| `sonarr-key` | `${SONARR_KEY}` | Set the env var instead of hard-coding |
|
||||
| `radarr-url` | `https://radarr.${BASE_HOST}` | |
|
||||
| `radarr-key` | `${RADARR_KEY}` | |
|
||||
| `lidarr-url` | `https://lidarr.${BASE_HOST}` | |
|
||||
| `lidarr-key` | `${LIDARR_KEY}` | |
|
||||
| `exclude-physical` | `true` | When `true`, Radarr movies whose display date is a physical release are hidden. Set to `false` to show them. |
|
||||
| `past-days` | `90` | How many days into the past to fetch (for the "this month" view, so the first week of the month shows releases from the previous month) |
|
||||
| `future-days` | `90` | How many days into the future to fetch (should be ≥ 60 to cover the full ~2-month view) |
|
||||
|
||||
If you want to keep the defaults but override the URLs, the cleanest
|
||||
pattern is to copy the widget's options block into your main
|
||||
`dynacat.yml` and `$include` the rest, or to edit `arr-calendar.yml`
|
||||
directly.
|
||||
|
||||
## Customising URLs
|
||||
|
||||
The default URLs use the `BASE_HOST` env var and assume Sonarr/Radarr/Lidarr
|
||||
are reachable as subdomains of that host. To use a different host for one
|
||||
of the services, override the corresponding `*-url` option in your
|
||||
`$include`. The YML supports Go template strings, so you can interpolate
|
||||
env vars:
|
||||
|
||||
```yaml
|
||||
- type: custom-api
|
||||
options:
|
||||
sonarr-url: https://sonarr.${BASE_HOST}
|
||||
sonarr-key: ${SONARR_KEY}
|
||||
radarr-url: http://192.168.1.50:7878 # absolute override
|
||||
radarr-key: ${RADARR_KEY}
|
||||
lidarr-key: ${LIDARR_KEY} # omitted URL → defaults to https://lidarr.${BASE_HOST}
|
||||
```
|
||||
|
||||
Note: at least one of the three `(url, key)` pairs must be set per service
|
||||
for the service to be queried. An empty `*-key` skips the service
|
||||
entirely — see [Partial-services support](#partial-services-support).
|
||||
|
||||
## Partial-services support
|
||||
|
||||
This is the headline feature. The widget keeps working with **any subset
|
||||
of the three services configured**:
|
||||
|
||||
- Set only `SONARR_KEY` → calendar shows only Sonarr releases (blue dots
|
||||
only)
|
||||
- Set all three → all three colours of dots appear
|
||||
- Set none → soft empty-state message: *"Configure at least one of
|
||||
SONARR_KEY, RADARR_KEY, or LIDARR_KEY in the container environment to
|
||||
show releases."*
|
||||
|
||||
A service is "configured" if both its URL and API key are non-empty. If
|
||||
either is empty, that service's API is silently skipped — no request
|
||||
goes out, no error widget appears, and the widget continues to render
|
||||
with the remaining services.
|
||||
|
||||
If a service is **configured** but **unreachable** (network down,
|
||||
service dead, 401 from a wrong key), the same logic applies: that
|
||||
service is skipped (the 5s HTTP timeout will trip), and the other
|
||||
services continue to render.
|
||||
|
||||
The status of each service is exposed to the client JS in a hidden
|
||||
`<ul class="arr-calendar-status">`:
|
||||
|
||||
```html
|
||||
<ul class="arr-calendar-status" hidden>
|
||||
<li data-service="sonarr" data-configured="true" data-ok="true"></li>
|
||||
<li data-service="radarr" data-configured="true" data-ok="false"></li> <!-- service down -->
|
||||
<li data-service="lidarr" data-configured="false" data-ok="true"></li> <!-- unconfigured = ok -->
|
||||
</ul>
|
||||
```
|
||||
|
||||
The widget JS doesn't currently surface this in the UI, but the data is
|
||||
there if you want to add a "Radarr is offline" pill in the popover.
|
||||
|
||||
## How it works (architecture)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Go template (server side, every update-interval = 10m) │
|
||||
│ 1. Compute $xxxEnabled = (URL != "") && (key != "") │
|
||||
│ 2. Declare $xxxData := newRequest "" | getResponse │
|
||||
│ (outer scope; empty URL → StatusCode 0 placeholder) │
|
||||
│ 3. Inside {{- if $xxxEnabled }}: │
|
||||
│ $xxxData = newRequest <real-url> | withHeader ... │
|
||||
│ | getResponse │
|
||||
│ 4. Emit hidden <li data-service=...> per release │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ HTTP response, XHR-rendered
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ <script src="/assets/arr-calendar.js" defer> │
|
||||
│ MutationObserver watches for the widget to mount │
|
||||
│ Reads the <li> elements, builds an in-memory model │
|
||||
│ Renders 6-week grid; click → popover with covers │
|
||||
│ Prev/next/undo buttons re-render with rAF slide+fade │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Why this split? Dynacat's `custom-api` widget re-renders **server-side**
|
||||
on the update interval. All interactivity (prev/next month, click
|
||||
popovers) is handled client-side in vanilla JS. The widget HTML
|
||||
includes a hidden `<ul class="arr-release-data">` with one `<li>` per
|
||||
release, encoded as `data-*` attributes. The client JS reads them with
|
||||
`element.getAttribute()`.
|
||||
|
||||
### Why isn't the JS in the YML too?
|
||||
|
||||
Per the HTML5 spec, `<script>` tags inserted via `innerHTML` (which is
|
||||
how Dynacat injects custom-api widget HTML) **do not execute**. Inline
|
||||
`<style>` blocks work because CSP allows `style-src 'self'
|
||||
'unsafe-inline'`; inline `<script>` is also allowed by CSP, but the
|
||||
browser still doesn't run scripts that arrived via innerHTML.
|
||||
`<script src="data:...">` is rejected by CSP (`data:` is not
|
||||
same-origin). The only working path: a real `.js` file at a real
|
||||
same-origin path, loaded via `<script src="...">` in
|
||||
`document.head`. That's the **one line** in your main `dynacat.yml`.
|
||||
|
||||
## File-by-file reference
|
||||
|
||||
### `arr-calendar.yml` (the widget)
|
||||
|
||||
- Top: `options:` block (env-var interpolation, all keys optional)
|
||||
- `template:` body:
|
||||
- Lines 1-12: Go template variables (`$radarrUrl`, `$sonarrKey`, etc.)
|
||||
- Lines 14-17: enabled flags, outer-scope `newRequest ""` defaults
|
||||
- Lines 19-35: per-service `newRequest` (only when enabled)
|
||||
- Lines 37-303: inline `<style>` block (all `arr-cal-*` CSS rules)
|
||||
- Lines 305-498: widget HTML shell + grid + popover + data `<ul>`
|
||||
|
||||
### `arr-calendar.js` (the client)
|
||||
|
||||
- Bootstrap: `MutationObserver` watches `document.body` for the widget
|
||||
to mount (Dynacat loads widget HTML via XHR, so the script can run
|
||||
before the widget is in the DOM)
|
||||
- Per-widget guard: `data-arr-inited="1"` attribute on the widget root
|
||||
prevents double-init when Dynacat's SSE re-morphs the widget HTML
|
||||
- Render: builds a 6-week grid (always 42 cells, with prev/next month
|
||||
spillover) into `data-arr-grid`
|
||||
- Click: shows a `position: fixed` popover with cover art, title,
|
||||
subtitle, link — portaled to `<body>` to escape ancestor
|
||||
`backdrop-filter` / `transform` containing blocks
|
||||
- Animation: manual rAF-driven `style.opacity` / `style.transform`
|
||||
interpolation on `data-arr-grid` for prev/next; same pattern for the
|
||||
undo button entrance
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Config has errors: custom-api widget: parsing template: ... function not defined"
|
||||
|
||||
This means the Go template engine doesn't recognise a function you used.
|
||||
Common offenders and fixes:
|
||||
|
||||
| Error | Cause | Fix |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------ | --- |
|
||||
| `function "dict" not defined` | Used `dict` to fabricate an empty response object | Use the outer-scope `newRequest ""` default pattern (see the YML) |
|
||||
| `function "withTimeout" not defined` | Added `| withTimeout "5s"` to the request chain | Remove it — Glance's default HTTP client already has a 5s timeout |
|
||||
| `function "subrequest" not defined` | Tried to use a non-existent helper | Check `https://raw.githubusercontent.com/glanceapp/glance/main/internal/glance/widget-custom-api.go` for the actual funcmap |
|
||||
|
||||
### "Config has errors: ... :NNN: undefined variable `$xxxData`"
|
||||
|
||||
You declared a variable with `:=` **inside** an `{{- if }}` block and then
|
||||
referenced it outside. Go's `text/template` scopes `:=` declarations to
|
||||
the enclosing block. Fix: declare the variable at the outer scope with
|
||||
`newRequest "" | getResponse` (gives a zero-valued response with
|
||||
`StatusCode = 0`), then use `=` (not `:=`) inside the if-block to
|
||||
reassign. See the "outer-scope default" pattern in `arr-calendar.yml`.
|
||||
|
||||
### "Config has errors: ... :NNN: ... " on the line that doesn't look related
|
||||
|
||||
Custom-api templates are parsed lazily on first render, and parse errors
|
||||
are only surfaced at container restart. The line number in the error
|
||||
message may not match the line of the actual bug — it's the byte offset
|
||||
in the template string when the parser hit the error. **Restart the
|
||||
container to see the error**, not the YML edit.
|
||||
|
||||
### Widget renders but no releases appear
|
||||
|
||||
- Check the browser DevTools console for errors in `arr-calendar.js`
|
||||
- Verify the env vars are set in the container: `docker exec dynacat env | grep -E "SONARR|RADARR|LIDARR"`
|
||||
- Test the API directly: `curl -H "X-Api-Key: $SONARR_KEY" "https://sonarr.${BASE_HOST}/api/v3/calendar?start=2026-06-01T00:00:00&end=2026-09-01T00:00:00&includeSeries=true"` — should return JSON with an array
|
||||
- Check the hidden status `<ul>`: open DevTools → Elements → search for `arr-calendar-status`. Each `<li>` has `data-configured` and `data-ok` flags. If a service has `data-ok="false"`, the API call failed.
|
||||
- If a service is missing from the `<ul>` entirely, the request block is being skipped — check that both the URL and key are non-empty in the rendered HTML (the `BASE_HOST` interpolation could be failing if `BASE_HOST` isn't set on the container)
|
||||
|
||||
### Popover doesn't appear when clicking a day
|
||||
|
||||
- Check the console for `Cannot set properties of null` or similar
|
||||
TypeErrors. Most common cause: the popover is being portaled to
|
||||
`<body>` on first show, then a second click tries to find it inside
|
||||
the widget and gets `null`. The shipped `arr-calendar.js` handles this
|
||||
via a `findPopover` helper.
|
||||
|
||||
### Animation is missing / stuck
|
||||
|
||||
The widget uses a manual rAF-driven animation (`grid.style.opacity` and
|
||||
`grid.style.transform` set every frame). If you see no animation:
|
||||
- Open DevTools → Elements → find `.arr-calendar-grid`
|
||||
- Click prev/next, sample `grid.style.opacity` in the console — values
|
||||
should change between 0 and 1 over ~700ms
|
||||
- If they don't, your Dynacat might be running an older build that
|
||||
doesn't include the manual rAF path. The Web Animations API approach
|
||||
has been observed to fail on this user's setup; CSS class-toggle has
|
||||
also been observed to fail silently. The rAF approach is the only one
|
||||
that's been confirmed to work.
|
||||
|
||||
## License
|
||||
|
||||
MIT. Do what you want with it. Cover art and release data belong to
|
||||
their respective rightsholders; this widget just displays them.
|
||||
|
||||
## Credits
|
||||
|
||||
- Built for [Dynacat](https://github.com/Panonim/dynacat), a fork of
|
||||
[Glance](https://github.com/glanceapp/glance) by Niklas Jonsson and
|
||||
contributors.
|
||||
- The slide+fade animation pattern is reverse-engineered from
|
||||
`internal/glance/static/js/calendar.js` in Glance.
|
||||
- The popover portal pattern (move to `<body>` to escape `backdrop-filter`
|
||||
containing blocks) is a well-known workaround documented across many
|
||||
CSS layout resources.
|
||||
|
||||
## How to add a screenshot
|
||||
|
||||
1. Open your dashboard
|
||||
2. Take a screenshot of the calendar widget area
|
||||
3. Save it as `docs/screenshot.png`
|
||||
4. Commit and push — the README will show it at the top
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
var WEEKDAYS_MON_FIRST = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
|
||||
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
function parseISODate(s) {
|
||||
var parts = s.split('-');
|
||||
return {
|
||||
y: parseInt(parts[0], 10),
|
||||
m: parseInt(parts[1], 10) - 1,
|
||||
d: parseInt(parts[2], 10)
|
||||
};
|
||||
}
|
||||
|
||||
function formatISO(y, m, d) {
|
||||
return y + '-' + pad2(m + 1) + '-' + pad2(d);
|
||||
}
|
||||
|
||||
function daysInMonth(y, m) {
|
||||
return new Date(y, m + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function collectReleases(widget) {
|
||||
var byDate = {};
|
||||
var lis = widget.querySelectorAll('.arr-release-data > li');
|
||||
for (var i = 0; i < lis.length; i++) {
|
||||
var li = lis[i];
|
||||
var date = li.getAttribute('data-date');
|
||||
if (!date) continue;
|
||||
if (!byDate[date]) byDate[date] = [];
|
||||
byDate[date].push({
|
||||
service: li.getAttribute('data-service'),
|
||||
date: date,
|
||||
title: li.getAttribute('data-title') || '',
|
||||
subtitle: li.getAttribute('data-subtitle') || '',
|
||||
link: li.getAttribute('data-link') || '#',
|
||||
cover: li.getAttribute('data-cover') || ''
|
||||
});
|
||||
}
|
||||
return byDate;
|
||||
}
|
||||
|
||||
// Build the rows for the current month. The calendar always renders
|
||||
// exactly 6 weeks (42 day cells) so its height is constant across all
|
||||
// months. The first row of cells starts on `firstDayOfWeek` (Monday)
|
||||
// and may include spillover days from the previous month; the last
|
||||
// row may include spillover days from the next month. This matches
|
||||
// Glance's built-in calendar (FULL_MONTH_SLOTS = 7 * 6).
|
||||
//
|
||||
// Spillover formula matches Glance's calendar.js exactly: when the
|
||||
// month starts on firstDayOfWeek itself, we treat that as "a full
|
||||
// previous week" (7 spillover days), not zero — this keeps every row
|
||||
// aligned to a complete Mon..Sun week.
|
||||
function buildRows(year, month, firstDayOfWeek) {
|
||||
if (firstDayOfWeek === undefined) firstDayOfWeek = 0; // 0 = Monday
|
||||
var firstDay = new Date(year, month, 1);
|
||||
// Monday-first: convert JS getDay() (0=Sun..6=Sat) to 0=Mon..6=Sun
|
||||
var firstWeekday = (firstDay.getDay() + 6) % 7;
|
||||
var totalDays = daysInMonth(year, month);
|
||||
var prevMonthDays = daysInMonth(year, month - 1);
|
||||
|
||||
// Header row (weekday labels) — separate from the day grid below.
|
||||
var rows = [];
|
||||
var headerRow = [];
|
||||
for (var w = 0; w < 7; w++) {
|
||||
headerRow.push({ type: 'weekday', text: WEEKDAYS_MON_FIRST[w] });
|
||||
}
|
||||
rows.push(headerRow);
|
||||
|
||||
// Distribute 42 cells into: prev-month spillover, current month,
|
||||
// next-month spillover. Matches Glance's `previousMonthSpilloverDays`
|
||||
// formula: when firstWeekday == firstDayOfWeek we render a full week
|
||||
// of prev-month spillover (7), not 0.
|
||||
var prevSpill = (firstWeekday - firstDayOfWeek + 7) % 7 || 7; // 1..7
|
||||
var currentDays = totalDays; // 28..31
|
||||
var nextSpill = 42 - (prevSpill + currentDays); // 4..14
|
||||
// Sanity guard — the math always adds to 42 for valid months, but if
|
||||
// something pathological happens, clamp to keep the layout stable.
|
||||
if (nextSpill < 0) nextSpill = 0;
|
||||
|
||||
// Build 6 rows of 7 cells each, filling in:
|
||||
// 1..prevSpill → previous-month spillover (from prev month)
|
||||
// prevSpill+1..prevSpill+currentDays → current month
|
||||
// the rest → next-month spillover
|
||||
var cellIndex = 0;
|
||||
for (var r = 0; r < 6; r++) {
|
||||
var row = [];
|
||||
for (var d = 0; d < 7; d++) {
|
||||
cellIndex++;
|
||||
if (cellIndex <= prevSpill) {
|
||||
// Previous-month spillover: day number is prevMonthDays - prevSpill + cellIndex
|
||||
row.push({
|
||||
type: 'day',
|
||||
day: prevMonthDays - prevSpill + cellIndex,
|
||||
outsideMonth: true,
|
||||
iso: null,
|
||||
releases: []
|
||||
});
|
||||
} else if (cellIndex <= prevSpill + currentDays) {
|
||||
var dayOfMonth = cellIndex - prevSpill;
|
||||
var iso = formatISO(year, month, dayOfMonth);
|
||||
row.push({
|
||||
type: 'day',
|
||||
day: dayOfMonth,
|
||||
outsideMonth: false,
|
||||
iso: iso,
|
||||
releases: [] // filled in by renderGrid
|
||||
});
|
||||
} else {
|
||||
// Next-month spillover: day number starts at 1
|
||||
var nextDay = cellIndex - (prevSpill + currentDays);
|
||||
row.push({
|
||||
type: 'day',
|
||||
day: nextDay,
|
||||
outsideMonth: true,
|
||||
iso: null,
|
||||
releases: []
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Animation constants matching the built-in Glance calendar's slide+fade
|
||||
// effect when navigating between months. Uses requestAnimationFrame to
|
||||
// manually drive the animation. This is the most reliable approach
|
||||
// because it doesn't depend on CSS transitions, Web Animations API, or
|
||||
// any specific browser behavior.
|
||||
var ANIM_DURATION_ENTER = 700;
|
||||
// Slide distance is 0.8rem — converted to pixels at call time using
|
||||
// the actual root font-size (Dynacat sets it to 10px, so 0.8rem ≈ 8px).
|
||||
var ANIM_DISTANCE_REM = 0.8;
|
||||
|
||||
// Render the new content first (so navigation is responsive even if the
|
||||
// animation fails for any reason), then apply the slide+fade animation
|
||||
// as a visual flourish.
|
||||
function animateGridChange(widgetOrGrid, renderNew, dir) {
|
||||
if (dir !== -1) dir = 1;
|
||||
|
||||
// Resolve the live grid element.
|
||||
var gridEl = widgetOrGrid;
|
||||
if (gridEl && gridEl.classList && !gridEl.classList.contains('arr-calendar-grid')) {
|
||||
gridEl = widgetOrGrid.querySelector('.arr-calendar-grid');
|
||||
}
|
||||
if (!gridEl) {
|
||||
renderNew();
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the actual pixel distance based on the root font size.
|
||||
// (CSS rem resolves to the root font-size; Dynacat sets it to 10px.)
|
||||
var rootStyle = window.getComputedStyle(document.documentElement);
|
||||
var rootFontSize = parseFloat(rootStyle.fontSize) || 10;
|
||||
var distancePx = ANIM_DISTANCE_REM * rootFontSize;
|
||||
|
||||
// Direction:
|
||||
// NEXT (forward in time): new month slides in from the RIGHT
|
||||
// PREV (backward in time): new month slides in from the LEFT
|
||||
var startX = dir > 0 ? distancePx : -distancePx;
|
||||
|
||||
// Render the new content first.
|
||||
renderNew();
|
||||
|
||||
// Animate via rAF. We use a "linear-tween" approach: compute progress
|
||||
// over the duration, interpolate opacity and transform.
|
||||
var startTime = null;
|
||||
function step(now) {
|
||||
if (startTime === null) startTime = now;
|
||||
var elapsed = now - startTime;
|
||||
var progress = Math.min(elapsed / ANIM_DURATION_ENTER, 1);
|
||||
// Use the same easing as Glance's calendar: cubic-bezier(0.22, 1, 0.36, 1)
|
||||
// Approximated as a simple easeOutCubic for performance.
|
||||
var eased = 1 - Math.pow(1 - progress, 3);
|
||||
gridEl.style.opacity = String(eased);
|
||||
gridEl.style.transform = 'translateX(' + (startX * (1 - eased)) + 'px)';
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
// Clean up inline styles when done
|
||||
gridEl.style.opacity = '';
|
||||
gridEl.style.transform = '';
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// Animate the undo button sliding in from the left (matches the built-in
|
||||
// Glance calendar's "calendar-undo-button" entrance animation). Uses
|
||||
// rAF-driven manual animation for reliability.
|
||||
function animateUndoEntrance(undoEl) {
|
||||
if (!undoEl || undoEl.hidden) return;
|
||||
// translateX(-100%) means shift by the element's own width; use
|
||||
// offsetWidth to compute the actual pixel distance.
|
||||
var startX = -undoEl.offsetWidth;
|
||||
var startTime = null;
|
||||
function step(now) {
|
||||
if (startTime === null) startTime = now;
|
||||
var elapsed = now - startTime;
|
||||
var progress = Math.min(elapsed / 400, 1);
|
||||
var eased = 1 - Math.pow(1 - progress, 3);
|
||||
undoEl.style.opacity = String(eased);
|
||||
undoEl.style.transform = 'translateX(' + (startX * (1 - eased)) + 'px)';
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
undoEl.style.opacity = '';
|
||||
undoEl.style.transform = '';
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function renderGrid(widget, year, month, byDate, today, todayParts) {
|
||||
var monthEl = widget.querySelector('[data-arr-month]');
|
||||
var yearEl = widget.querySelector('[data-arr-year]');
|
||||
var monthNumEl = widget.querySelector('[data-arr-monthnum]');
|
||||
var undoEl = widget.querySelector('[data-arr-undo]');
|
||||
var gridEl = widget.querySelector('[data-arr-grid]');
|
||||
|
||||
monthEl.textContent = MONTH_NAMES[month];
|
||||
yearEl.textContent = year;
|
||||
monthNumEl.textContent = pad2(month + 1);
|
||||
// Show undo button only when not viewing the current month
|
||||
var onCurrent = (year === todayParts.y && month === todayParts.m);
|
||||
undoEl.hidden = onCurrent;
|
||||
|
||||
gridEl.innerHTML = '';
|
||||
var rows = buildRows(year, month);
|
||||
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
var row = rows[r];
|
||||
// Group weekday cells: render header row as plain text cells
|
||||
if (row[0].type === 'weekday') {
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var wd = document.createElement('div');
|
||||
wd.className = 'arr-cal-weekday';
|
||||
wd.textContent = row[i].text;
|
||||
gridEl.appendChild(wd);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (var j = 0; j < 7; j++) {
|
||||
var cellData = row[j];
|
||||
var cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'arr-cal-cell';
|
||||
if (cellData.outsideMonth) {
|
||||
cell.classList.add('arr-cal-outside');
|
||||
} else {
|
||||
var releases = byDate[cellData.iso] || [];
|
||||
cellData.releases = releases;
|
||||
cell.setAttribute('data-date', cellData.iso);
|
||||
cell.setAttribute('aria-label', cellData.iso +
|
||||
(releases.length > 0 ? ', ' + releases.length + ' releases' : ''));
|
||||
if (cellData.iso === today) cell.classList.add('arr-cal-today');
|
||||
if (releases.length > 0) cell.classList.add('arr-cal-has-releases');
|
||||
}
|
||||
|
||||
var num = document.createElement('span');
|
||||
num.className = 'arr-cal-num';
|
||||
num.textContent = String(cellData.day);
|
||||
cell.appendChild(num);
|
||||
|
||||
if (!cellData.outsideMonth && cellData.releases.length > 0) {
|
||||
var dots = document.createElement('span');
|
||||
dots.className = 'arr-cal-dots';
|
||||
var seen = {};
|
||||
for (var k = 0; k < cellData.releases.length; k++) {
|
||||
var svc = cellData.releases[k].service;
|
||||
if (seen[svc]) continue;
|
||||
seen[svc] = true;
|
||||
var dot = document.createElement('span');
|
||||
dot.className = 'arr-dot arr-dot-' + svc;
|
||||
dots.appendChild(dot);
|
||||
}
|
||||
cell.appendChild(dots);
|
||||
|
||||
(function (cd, c) {
|
||||
c.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
showPopover(widget, c, cd.releases);
|
||||
});
|
||||
})(cellData, cell);
|
||||
} else if (!cellData.outsideMonth) {
|
||||
(function (c) {
|
||||
c.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
hidePopover(widget);
|
||||
});
|
||||
})(cell);
|
||||
}
|
||||
|
||||
gridEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position the popover relative to the viewport (position: fixed) so it
|
||||
// can overlay any other widget on the page. The popover must live in
|
||||
// document.body, NOT inside the widget, because the widget is wrapped by
|
||||
// .widget-content (which has backdrop-filter: blur) — and backdrop-filter
|
||||
// creates a containing block for position:fixed descendants, which would
|
||||
// clip the popover to the widget. Moving it to <body> avoids that.
|
||||
function positionPopover(pop, cell) {
|
||||
var rect = cell.getBoundingClientRect();
|
||||
var popRect = pop.getBoundingClientRect();
|
||||
var margin = 8;
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
|
||||
// Default: place below the cell, horizontally aligned to its left edge
|
||||
var top = rect.bottom + margin;
|
||||
var left = rect.left;
|
||||
|
||||
// If popover would overflow the bottom, place it above the cell
|
||||
if (top + popRect.height > vh - margin) {
|
||||
top = rect.top - popRect.height - margin;
|
||||
}
|
||||
// If still off-screen (cell near top), clamp to viewport
|
||||
if (top < margin) top = margin;
|
||||
|
||||
// If popover would overflow the right edge, shift left
|
||||
if (left + popRect.width > vw - margin) {
|
||||
left = vw - popRect.width - margin;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
|
||||
pop.style.position = 'fixed';
|
||||
pop.style.top = top + 'px';
|
||||
pop.style.left = left + 'px';
|
||||
}
|
||||
|
||||
function showPopover(widget, cell, releases) {
|
||||
var pop = findPopover(widget);
|
||||
if (!pop) {
|
||||
// No popover element found anywhere — should not happen since the
|
||||
// widget template ships one, but be defensive.
|
||||
return;
|
||||
}
|
||||
pop.innerHTML = '';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'arr-popover-header';
|
||||
var iso = cell.getAttribute('data-date');
|
||||
var d = parseISODate(iso);
|
||||
var headerDate = new Date(d.y, d.m, d.d);
|
||||
var opts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
header.textContent = headerDate.toLocaleDateString(undefined, opts);
|
||||
pop.appendChild(header);
|
||||
|
||||
if (releases.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'arr-popover-empty';
|
||||
empty.textContent = 'No releases on this day.';
|
||||
pop.appendChild(empty);
|
||||
} else {
|
||||
var list = document.createElement('ul');
|
||||
list.className = 'arr-popover-list';
|
||||
releases.sort(function (a, b) {
|
||||
return a.service.localeCompare(b.service);
|
||||
});
|
||||
for (var i = 0; i < releases.length; i++) {
|
||||
var rel = releases[i];
|
||||
var item = document.createElement('li');
|
||||
item.className = 'arr-popover-item arr-svc-' + rel.service;
|
||||
|
||||
var cover = document.createElement('img');
|
||||
cover.className = 'arr-popover-cover';
|
||||
cover.loading = 'lazy';
|
||||
cover.alt = '';
|
||||
if (rel.cover) {
|
||||
cover.src = rel.cover;
|
||||
} else {
|
||||
cover.style.display = 'none';
|
||||
}
|
||||
item.appendChild(cover);
|
||||
|
||||
var text = document.createElement('div');
|
||||
text.className = 'arr-popover-text';
|
||||
|
||||
var titleEl = document.createElement('a');
|
||||
titleEl.className = 'arr-popover-title';
|
||||
titleEl.href = rel.link;
|
||||
titleEl.target = '_blank';
|
||||
titleEl.rel = 'noopener noreferrer';
|
||||
titleEl.textContent = rel.title;
|
||||
text.appendChild(titleEl);
|
||||
|
||||
var sub = document.createElement('div');
|
||||
sub.className = 'arr-popover-sub';
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'arr-popover-badge arr-svc-badge-' + rel.service;
|
||||
badge.textContent = rel.service;
|
||||
sub.appendChild(badge);
|
||||
var subText = document.createTextNode(' ' + rel.subtitle);
|
||||
sub.appendChild(subText);
|
||||
text.appendChild(sub);
|
||||
|
||||
item.appendChild(text);
|
||||
list.appendChild(item);
|
||||
}
|
||||
pop.appendChild(list);
|
||||
}
|
||||
|
||||
// Move the popover out of the widget to <body> so it can use
|
||||
// position:fixed relative to the viewport. The widget is wrapped by
|
||||
// .widget-content which has backdrop-filter, and backdrop-filter
|
||||
// creates a containing block for position:fixed descendants.
|
||||
if (pop.parentElement !== document.body) {
|
||||
document.body.appendChild(pop);
|
||||
}
|
||||
// Tag the popover with its owning widget id so we can find it again
|
||||
// after it's been moved (findPopover uses this).
|
||||
pop.dataset.widgetId = widget.dataset.arrId;
|
||||
|
||||
// Make visible first (so getBoundingClientRect works), then position
|
||||
pop.hidden = false;
|
||||
pop.style.top = '0px';
|
||||
pop.style.left = '0px';
|
||||
// Force layout, then position relative to the clicked cell
|
||||
positionPopover(pop, cell);
|
||||
pop.dataset.date = iso;
|
||||
}
|
||||
|
||||
function hidePopover(widget) {
|
||||
var pop = findPopover(widget);
|
||||
if (!pop) return;
|
||||
pop.hidden = true;
|
||||
pop.innerHTML = '';
|
||||
delete pop.dataset.date;
|
||||
// Move the popover back inside the widget so it stays associated with it
|
||||
// (and so the next showPopover will find it there).
|
||||
if (pop.parentElement === document.body) {
|
||||
widget.appendChild(pop);
|
||||
}
|
||||
}
|
||||
|
||||
function initWidget(widget) {
|
||||
ensureWidgetId(widget);
|
||||
if (widget.dataset.arrInited === '1') {
|
||||
// Already inited — but Dynacat's SSE may have re-morphed the widget
|
||||
// HTML in place. In that case the cells were replaced and lost their
|
||||
// click handlers. Detect a fresh grid (no children yet) and re-render.
|
||||
var existingGrid = widget.querySelector('.arr-calendar-grid');
|
||||
if (existingGrid && existingGrid.children.length > 0) return;
|
||||
}
|
||||
widget.dataset.arrInited = '1';
|
||||
|
||||
var today = widget.getAttribute('data-today') ||
|
||||
formatISO(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
|
||||
var todayParts = parseISODate(today);
|
||||
var byDate = collectReleases(widget);
|
||||
var view = { y: todayParts.y, m: todayParts.m };
|
||||
|
||||
function render() {
|
||||
renderGrid(widget, view.y, view.m, byDate, today, todayParts);
|
||||
}
|
||||
|
||||
// Track whether the undo button is currently visible, so we can decide
|
||||
// whether to animate its entrance on the next render.
|
||||
var undoWasVisible = false;
|
||||
|
||||
// Capture the current grid state for animation, then re-render with
|
||||
// the new month and animate the change. The grid element is looked up
|
||||
// FRESH on each call so that the animation applies to the live grid
|
||||
// even if the widget was re-morphed by Dynacat's SSE update between
|
||||
// page load and now (a closure-captured reference would be stale).
|
||||
function navigateWithTransition(dir) {
|
||||
if (dir !== -1) dir = 1;
|
||||
var onCurrent = (view.y === todayParts.y && view.m === todayParts.m);
|
||||
var undoWillBeVisible = !onCurrent;
|
||||
|
||||
animateGridChange(widget, function () {
|
||||
render();
|
||||
}, dir);
|
||||
|
||||
if (undoWillBeVisible && !undoWasVisible) {
|
||||
// Wait a tick so the DOM updates before animating
|
||||
setTimeout(function () {
|
||||
animateUndoEntrance(widget.querySelector('[data-arr-undo]'));
|
||||
}, 0);
|
||||
}
|
||||
undoWasVisible = undoWillBeVisible;
|
||||
}
|
||||
|
||||
widget.querySelector('[data-arr-nav="prev"]').addEventListener('click', function () {
|
||||
view.m -= 1;
|
||||
if (view.m < 0) { view.m = 11; view.y -= 1; }
|
||||
hidePopover(widget);
|
||||
navigateWithTransition(-1);
|
||||
});
|
||||
widget.querySelector('[data-arr-nav="next"]').addEventListener('click', function () {
|
||||
view.m += 1;
|
||||
if (view.m > 11) { view.m = 0; view.y += 1; }
|
||||
hidePopover(widget);
|
||||
navigateWithTransition(1);
|
||||
});
|
||||
widget.querySelector('[data-arr-undo]').addEventListener('click', function () {
|
||||
// Direction: if current view is in the past, undo = forward (+1).
|
||||
// If in the future, undo = backward (-1).
|
||||
var dir = 1;
|
||||
if (view.y > todayParts.y || (view.y === todayParts.y && view.m > todayParts.m)) {
|
||||
dir = -1;
|
||||
}
|
||||
view.y = todayParts.y;
|
||||
view.m = todayParts.m;
|
||||
hidePopover(widget);
|
||||
navigateWithTransition(dir);
|
||||
});
|
||||
|
||||
render();
|
||||
undoWasVisible = false; // initial render, undo is hidden
|
||||
}
|
||||
|
||||
// Find the popover element. It normally lives inside the widget, but
|
||||
// during display it's moved to <body> to escape the widget's
|
||||
// backdrop-filter containing block. Look in both places.
|
||||
function findPopover(widget) {
|
||||
var pop = widget.querySelector('[data-arr-popover]');
|
||||
if (pop) return pop;
|
||||
// The popover may be on <body>. Find it by its dataset/attribute.
|
||||
var bodyPops = document.body.querySelectorAll('[data-arr-popover].arr-calendar-popover');
|
||||
for (var i = 0; i < bodyPops.length; i++) {
|
||||
var bp = bodyPops[i];
|
||||
if (bp.dataset.widgetId === widget.dataset.arrId) return bp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign a stable id to the widget so we can find its popover on <body>.
|
||||
function ensureWidgetId(widget) {
|
||||
if (!widget.dataset.arrId) {
|
||||
widget.dataset.arrId = 'arrcal_' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
return widget.dataset.arrId;
|
||||
}
|
||||
|
||||
// Close popover when clicking outside any arr-calendar-widget
|
||||
document.addEventListener('click', function (ev) {
|
||||
var widgets = document.querySelectorAll('.arr-calendar-widget');
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
var w = widgets[i];
|
||||
if (w.contains(ev.target)) continue;
|
||||
var pop = findPopover(w);
|
||||
if (pop && !pop.hidden) hidePopover(w);
|
||||
}
|
||||
});
|
||||
|
||||
// Esc closes popover
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key !== 'Escape') return;
|
||||
var widgets = document.querySelectorAll('.arr-calendar-widget');
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
hidePopover(widgets[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// Reposition popover on scroll/resize so it stays attached to its cell
|
||||
window.addEventListener('scroll', function () {
|
||||
var widgets = document.querySelectorAll('.arr-calendar-widget');
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
var widget = widgets[i];
|
||||
var pop = findPopover(widget);
|
||||
if (!pop || pop.hidden) continue;
|
||||
var iso = pop.dataset.date;
|
||||
if (!iso) continue;
|
||||
var cell = widget.querySelector('.arr-cal-cell[data-date="' + iso + '"]');
|
||||
if (cell) positionPopover(pop, cell);
|
||||
}
|
||||
}, true);
|
||||
window.addEventListener('resize', function () {
|
||||
var widgets = document.querySelectorAll('.arr-calendar-widget');
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
var widget = widgets[i];
|
||||
var pop = findPopover(widget);
|
||||
if (!pop || pop.hidden) continue;
|
||||
var iso = pop.dataset.date;
|
||||
if (!iso) continue;
|
||||
var cell = widget.querySelector('.arr-cal-cell[data-date="' + iso + '"]');
|
||||
if (cell) positionPopover(pop, cell);
|
||||
}
|
||||
});
|
||||
|
||||
// Boot: scan DOM now, then watch for widgets added later (Dynacat's
|
||||
// custom-api widgets are inserted via innerHTML/XHR after page load,
|
||||
// which means our <script src="arr-calendar.js"> tag inside the widget
|
||||
// is never executed, and the initial scan() finds zero widgets).
|
||||
function scan() {
|
||||
var widgets = document.querySelectorAll('.arr-calendar-widget:not([data-arr-inited])');
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
initWidget(widgets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function startObserver() {
|
||||
scan();
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
var found = false;
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var added = mutations[i].addedNodes;
|
||||
for (var j = 0; j < added.length; j++) {
|
||||
var n = added[j];
|
||||
if (n.nodeType !== 1) continue;
|
||||
if (n.classList && n.classList.contains('arr-calendar-widget')) {
|
||||
initWidget(n);
|
||||
found = true;
|
||||
} else if (n.querySelectorAll) {
|
||||
var inner = n.querySelectorAll('.arr-calendar-widget');
|
||||
for (var k = 0; k < inner.length; k++) {
|
||||
initWidget(inner[k]);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) scan();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
startObserver();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', startObserver);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,505 @@
|
||||
- type: custom-api
|
||||
title: Arr Calendar
|
||||
hide-header: true
|
||||
update-interval: 10m
|
||||
options:
|
||||
radarr-url: https://radarr.${BASE_HOST}
|
||||
radarr-key: ${RADARR_KEY}
|
||||
sonarr-url: https://sonarr.${BASE_HOST}
|
||||
sonarr-key: ${SONARR_KEY}
|
||||
lidarr-url: https://lidarr.${BASE_HOST}
|
||||
lidarr-key: ${LIDARR_KEY}
|
||||
exclude-physical: true
|
||||
past-days: 90
|
||||
future-days: 90
|
||||
template: |
|
||||
{{- /* === *arr Calendar Widget — self-contained === */}}
|
||||
{{- /* CSS is inlined below (works because Dynacat CSP allows style-src */}}
|
||||
{{- /* 'self' 'unsafe-inline'). JS lives in /assets/arr-calendar.js and */}}
|
||||
{{- /* must be wired once in dynacat.yml > document.head: */}}
|
||||
{{- /* <script src="/assets/arr-calendar.js" defer></script> */}}
|
||||
|
||||
<style>
|
||||
.arr-calendar-widget {
|
||||
--arr-sonarr: #3b82f6;
|
||||
--arr-radarr: #22c55e;
|
||||
--arr-lidarr: #a855f7;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
}
|
||||
.arr-calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.6rem;
|
||||
min-height: 2.8rem;
|
||||
}
|
||||
.arr-cal-header-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
height: 2.8rem;
|
||||
}
|
||||
.arr-cal-month {
|
||||
font-size: var(--font-size-h2, 1.6rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-highlight);
|
||||
line-height: 1;
|
||||
}
|
||||
.arr-cal-year {
|
||||
font-size: var(--font-size-h3, 1.5rem);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-highlight);
|
||||
line-height: 1;
|
||||
}
|
||||
.arr-cal-undo {
|
||||
background: none;
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
color: var(--color-text-base);
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: 0.2rem;
|
||||
border-radius: var(--border-radius, 5px);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.arr-cal-undo[hidden] { display: none; }
|
||||
.arr-cal-undo svg { width: 2rem; height: 2rem; }
|
||||
.arr-cal-undo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.2rem;
|
||||
border-radius: var(--border-radius, 5px);
|
||||
background-color: var(--color-text-subdue);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
.arr-cal-undo:hover { color: var(--color-text-highlight); }
|
||||
.arr-cal-undo:hover::before { opacity: 0.4; }
|
||||
.arr-cal-header-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.arr-cal-monthnum {
|
||||
font-size: var(--font-size-h3, 1.5rem);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-highlight);
|
||||
line-height: 1;
|
||||
margin-top: 0.1rem;
|
||||
min-width: 1.4rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.arr-cal-nav {
|
||||
background: none;
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
color: var(--color-text-base);
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius, 5px);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.arr-cal-nav svg { width: 2rem; height: 2rem; }
|
||||
.arr-cal-nav::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.2rem;
|
||||
border-radius: var(--border-radius, 5px);
|
||||
background-color: var(--color-text-subdue);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
.arr-cal-nav:hover { color: var(--color-text-highlight); }
|
||||
.arr-cal-nav:hover::before { opacity: 0.4; }
|
||||
.arr-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.arr-cal-weekday {
|
||||
font-size: var(--font-size-h6, 1.1rem);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-subdue);
|
||||
padding: 0.3rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.arr-cal-cell {
|
||||
position: relative;
|
||||
padding: 0.4rem 0;
|
||||
color: var(--color-text-base);
|
||||
border-radius: var(--border-radius, 5px);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: var(--font-size-base, 1.3rem);
|
||||
cursor: pointer;
|
||||
height: 3.2rem;
|
||||
min-height: 3.2rem;
|
||||
max-height: 3.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
.arr-cal-cell:hover:not(.arr-cal-outside) {
|
||||
background-color: var(--color-widget-background-highlight);
|
||||
}
|
||||
.arr-cal-outside {
|
||||
color: var(--color-text-subdue);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.arr-cal-today {
|
||||
background-color: var(--color-popover-border);
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
.arr-cal-has-releases { font-weight: 600; }
|
||||
.arr-cal-num {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: 1;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
.arr-cal-dots {
|
||||
position: absolute;
|
||||
bottom: 0.15rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
line-height: 0;
|
||||
}
|
||||
.arr-dot {
|
||||
display: inline-block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.arr-dot-sonarr { background: var(--arr-sonarr); }
|
||||
.arr-dot-radarr { background: var(--arr-radarr); }
|
||||
.arr-dot-lidarr { background: var(--arr-lidarr); }
|
||||
.arr-calendar-popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: max-content;
|
||||
max-width: min(360px, calc(100vw - 2rem));
|
||||
min-width: 260px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: var(--color-popover-background);
|
||||
border: 1px solid var(--color-popover-border);
|
||||
border-radius: var(--border-radius, 5px);
|
||||
padding: 0.75rem 0.9rem;
|
||||
box-shadow: 0 15px 20px -10px hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
|
||||
color: var(--color-text-paragraph);
|
||||
}
|
||||
.arr-popover-header {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-h3, 1.5rem);
|
||||
margin-bottom: 0.6rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.arr-popover-empty {
|
||||
font-size: var(--font-size-base, 1.3rem);
|
||||
opacity: 0.7;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.arr-popover-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.arr-popover-item {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.arr-popover-item.arr-svc-sonarr { border-left-color: var(--arr-sonarr); }
|
||||
.arr-popover-item.arr-svc-radarr { border-left-color: var(--arr-radarr); }
|
||||
.arr-popover-item.arr-svc-lidarr { border-left-color: var(--arr-lidarr); }
|
||||
.arr-popover-cover {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.arr-popover-text { flex: 1; min-width: 0; }
|
||||
.arr-popover-title {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-h4, 1.4rem);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.arr-popover-title:hover { color: var(--color-primary, #a78bfa); }
|
||||
.arr-popover-sub {
|
||||
font-size: var(--font-size-h6, 1.1rem);
|
||||
opacity: 0.8;
|
||||
margin-top: 0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.arr-popover-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.arr-svc-badge-sonarr { background: var(--arr-sonarr); }
|
||||
.arr-svc-badge-radarr { background: var(--arr-radarr); }
|
||||
.arr-svc-badge-lidarr { background: var(--arr-lidarr); }
|
||||
.arr-release-data,
|
||||
.arr-calendar-status { display: none !important; }
|
||||
.arr-calendar-empty {
|
||||
font-size: var(--font-size-base, 1.3rem);
|
||||
color: var(--color-text-subdue);
|
||||
padding: 1.2rem 0.4rem;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.arr-calendar-empty code {
|
||||
font-size: 0.95em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
background: var(--color-widget-background-highlight, rgba(255,255,255,0.05));
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ $radarrUrl := .Options.StringOr "radarr-url" "" }}
|
||||
{{ $sonarrUrl := .Options.StringOr "sonarr-url" "" }}
|
||||
{{ $lidarrUrl := .Options.StringOr "lidarr-url" "" }}
|
||||
{{ $radarrKey := .Options.StringOr "radarr-key" "" }}
|
||||
{{ $sonarrKey := .Options.StringOr "sonarr-key" "" }}
|
||||
{{ $lidarrKey := .Options.StringOr "lidarr-key" "" }}
|
||||
{{ $excludePhysical := .Options.BoolOr "exclude-physical" false }}
|
||||
{{ $pastDays := .Options.IntOr "past-days" 90 }}
|
||||
{{ $futureDays := .Options.IntOr "future-days" 90 }}
|
||||
|
||||
{{ $pastH := mul $pastDays 24 }}
|
||||
{{ $futureH := mul $futureDays 24 }}
|
||||
|
||||
{{ $start := offsetNow (printf "-%dh" $pastH) | formatTime "2006-01-02T15:04:05" }}
|
||||
{{ $end := offsetNow (printf "+%dh" $futureH) | formatTime "2006-01-02T15:04:05" }}
|
||||
{{ $nowIso := now | formatTime "2006-01-02T15:04:05" }}
|
||||
|
||||
{{- /* A service is "configured" if BOTH the URL and the API key are set.
|
||||
Unconfigured services are silently skipped — no request, no error.
|
||||
|
||||
We declare $xxxData at the OUTER scope with an empty URL, which
|
||||
Glance's getResponse() handles by returning a zero-valued response
|
||||
with Response.StatusCode = 0 (see fetchCustomAPIResponse in
|
||||
widget-custom-api.go: empty URL short-circuits to a no-op). The
|
||||
outer-scope declaration is required because Go text/template
|
||||
variables declared with `:=` inside an if-block are scoped to that
|
||||
block — referencing them outside the block yields "undefined
|
||||
variable". The if-block then reassigns the outer variable with `=`
|
||||
when the service is enabled. */}}
|
||||
{{ $radarrEnabled := and (ne $radarrUrl "") (ne $radarrKey "") }}
|
||||
{{ $sonarrEnabled := and (ne $sonarrUrl "") (ne $sonarrKey "") }}
|
||||
{{ $lidarrEnabled := and (ne $lidarrUrl "") (ne $lidarrKey "") }}
|
||||
|
||||
{{- $radarrData := newRequest "" | getResponse }}
|
||||
{{- $sonarrData := newRequest "" | getResponse }}
|
||||
{{- $lidarrData := newRequest "" | getResponse }}
|
||||
|
||||
{{- if $radarrEnabled }}
|
||||
{{ $radarrReq := printf "%s/api/v3/calendar?start=%s&end=%s" $radarrUrl $start $end }}
|
||||
{{- $radarrData = newRequest $radarrReq
|
||||
| withHeader "Accept" "application/json"
|
||||
| withHeader "X-Api-Key" $radarrKey
|
||||
| getResponse }}
|
||||
{{- end }}
|
||||
|
||||
{{- if $sonarrEnabled }}
|
||||
{{ $sonarrReq := printf "%s/api/v3/calendar?includeSeries=true&start=%s&end=%s" $sonarrUrl $start $end }}
|
||||
{{- $sonarrData = newRequest $sonarrReq
|
||||
| withHeader "Accept" "application/json"
|
||||
| withHeader "X-Api-Key" $sonarrKey
|
||||
| getResponse }}
|
||||
{{- end }}
|
||||
|
||||
{{- if $lidarrEnabled }}
|
||||
{{ $lidarrReq := printf "%s/api/v1/calendar?start=%s&end=%s" $lidarrUrl $start $end }}
|
||||
{{- $lidarrData = newRequest $lidarrReq
|
||||
| withHeader "Accept" "application/json"
|
||||
| withHeader "X-Api-Key" $lidarrKey
|
||||
| getResponse }}
|
||||
{{- end }}
|
||||
|
||||
{{ $anyConfigured := or $radarrEnabled $sonarrEnabled $lidarrEnabled }}
|
||||
|
||||
<div class="arr-calendar-widget" data-today="{{ now | formatTime "2006-01-02" }}">
|
||||
<div class="arr-calendar-header">
|
||||
<div class="arr-cal-header-left">
|
||||
<div class="arr-cal-month" data-arr-month></div>
|
||||
<div class="arr-cal-year" data-arr-year></div>
|
||||
<button class="arr-cal-undo" type="button" data-arr-undo title="Back to current month" hidden>
|
||||
<svg stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="1.8" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="arr-cal-header-right">
|
||||
<button class="arr-cal-nav" type="button" data-arr-nav="prev" title="Previous month" aria-label="Previous month">
|
||||
<svg stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="2" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 6 8 12l6 6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="arr-cal-monthnum" data-arr-monthnum></div>
|
||||
<button class="arr-cal-nav" type="button" data-arr-nav="next" title="Next month" aria-label="Next month">
|
||||
<svg stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="2" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m10 6 6 6-6 6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arr-calendar-grid" data-arr-grid></div>
|
||||
<div class="arr-calendar-popover" data-arr-popover hidden></div>
|
||||
|
||||
<ul class="arr-release-data" hidden>
|
||||
{{- if and $radarrEnabled (eq $radarrData.Response.StatusCode 200) }}
|
||||
{{- range $radarrData.JSON.Array "" }}
|
||||
{{- $title := .String "title" }}
|
||||
{{- $id := .Int "id" }}
|
||||
{{- $titleSlug := .String "titleSlug" }}
|
||||
{{- $link := printf "%s/movie/%s" $radarrUrl $titleSlug }}
|
||||
{{- $coverUrl := printf "%s/api/v3/mediacover/%d/poster-250.jpg?apikey=%s" $radarrUrl $id $radarrKey }}
|
||||
|
||||
{{- $releaseDate := .String "releaseDate" }}
|
||||
{{- $releaseType := "Release" }}
|
||||
{{- $selectedDate := $releaseDate }}
|
||||
|
||||
{{- $inCinemas := "" }}{{ if .Exists "inCinemas" }}{{ $inCinemas = .String "inCinemas" }}{{ end }}
|
||||
{{- $digitalRelease := "" }}{{ if .Exists "digitalRelease" }}{{ $digitalRelease = .String "digitalRelease" }}{{ end }}
|
||||
{{- $physicalRelease := "" }}{{ if .Exists "physicalRelease" }}{{ $physicalRelease = .String "physicalRelease" }}{{ end }}
|
||||
|
||||
{{- if and (ne $physicalRelease "") (gt $physicalRelease $nowIso) }}
|
||||
{{- $releaseType = "Physical" }}
|
||||
{{- $selectedDate = $physicalRelease }}
|
||||
{{- end }}
|
||||
{{- if and (ne $digitalRelease "") (gt $digitalRelease $nowIso) }}
|
||||
{{- if or (eq $selectedDate $releaseDate) (lt $digitalRelease $selectedDate) }}
|
||||
{{- $releaseType = "Digital" }}
|
||||
{{- $selectedDate = $digitalRelease }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if and (ne $inCinemas "") (gt $inCinemas $nowIso) }}
|
||||
{{- if or (eq $selectedDate $releaseDate) (lt $inCinemas $selectedDate) }}
|
||||
{{- $releaseType = "In Cinemas" }}
|
||||
{{- $selectedDate = $inCinemas }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if or (not $excludePhysical) (ne $releaseType "Physical") }}
|
||||
{{- $dateOnly := $selectedDate | parseTime "RFC3339" | formatTime "2006-01-02" }}
|
||||
<li data-service="radarr" data-date="{{ $dateOnly }}" data-title="{{ $title }}" data-subtitle="{{ $releaseType }}" data-link="{{ $link }}" data-cover="{{ $coverUrl }}"></li>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if and $sonarrEnabled (eq $sonarrData.Response.StatusCode 200) }}
|
||||
{{- range $sonarrData.JSON.Array "" }}
|
||||
{{- $seriesId := .Int "seriesId" }}
|
||||
{{- $coverUrl := printf "%s/api/v3/mediacover/%d/poster-250.jpg?apikey=%s" $sonarrUrl $seriesId $sonarrKey }}
|
||||
{{- $title := "" }}{{ if .Exists "series.title" }}{{ $title = .String "series.title" }}{{ end }}
|
||||
{{- $titleSlug := "" }}{{ if .Exists "series.titleSlug" }}{{ $titleSlug = .String "series.titleSlug" }}{{ end }}
|
||||
{{- $link := printf "%s/series/%s" $sonarrUrl $titleSlug }}
|
||||
{{- $dateOnly := .String "airDateUtc" | parseTime "RFC3339" | formatTime "2006-01-02" }}
|
||||
{{- $seString := printf "S%02dE%02d" (.Int "seasonNumber") (.Int "episodeNumber") }}
|
||||
{{- $epTitle := .String "title" }}
|
||||
{{- $subtitle := printf "%s - %s" $seString $epTitle }}
|
||||
<li data-service="sonarr" data-date="{{ $dateOnly }}" data-title="{{ $title }}" data-subtitle="{{ $subtitle }}" data-link="{{ $link }}" data-cover="{{ $coverUrl }}"></li>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if and $lidarrEnabled (eq $lidarrData.Response.StatusCode 200) }}
|
||||
{{- range $lidarrData.JSON.Array "" }}
|
||||
{{- $albumId := .Int "id" }}
|
||||
{{- $title := "" }}{{ if .Exists "title" }}{{ $title = .String "title" }}{{ end }}
|
||||
{{- $artistTitle := "" }}{{ if .Exists "artist.artistName" }}{{ $artistTitle = .String "artist.artistName" }}{{ end }}
|
||||
{{- $foreignAlbumId := "" }}{{ if .Exists "foreignAlbumId" }}{{ $foreignAlbumId = .String "foreignAlbumId" }}{{ end }}
|
||||
{{- $link := printf "%s/album/%s" $lidarrUrl $foreignAlbumId }}
|
||||
{{- $coverUrl := printf "%s/api/v1/MediaCover/album/%d/poster-250.jpg?apikey=%s" $lidarrUrl $albumId $lidarrKey }}
|
||||
{{- if .Exists "releaseDate" }}
|
||||
{{- $dateOnly := .String "releaseDate" | parseTime "RFC3339" | formatTime "2006-01-02" }}
|
||||
<li data-service="lidarr" data-date="{{ $dateOnly }}" data-title="{{ $title }}" data-subtitle="{{ $artistTitle }}" data-link="{{ $link }}" data-cover="{{ $coverUrl }}"></li>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
|
||||
<ul class="arr-calendar-status" hidden>
|
||||
{{- if $sonarrEnabled }}
|
||||
<li data-service="sonarr" data-configured="true" data-ok="{{ eq $sonarrData.Response.StatusCode 200 }}"></li>
|
||||
{{- else }}
|
||||
<li data-service="sonarr" data-configured="false" data-ok="true"></li>
|
||||
{{- end }}
|
||||
{{- if $radarrEnabled }}
|
||||
<li data-service="radarr" data-configured="true" data-ok="{{ eq $radarrData.Response.StatusCode 200 }}"></li>
|
||||
{{- else }}
|
||||
<li data-service="radarr" data-configured="false" data-ok="true"></li>
|
||||
{{- end }}
|
||||
{{- if $lidarrEnabled }}
|
||||
<li data-service="lidarr" data-configured="true" data-ok="{{ eq $lidarrData.Response.StatusCode 200 }}"></li>
|
||||
{{- else }}
|
||||
<li data-service="lidarr" data-configured="false" data-ok="true"></li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{- /* Soft empty-state message when the user hasn't configured any service.
|
||||
We intentionally do NOT render the giant widget-error banner — partial
|
||||
availability (e.g. Sonarr-only) is success, not an error. */}}
|
||||
{{ if not $anyConfigured }}
|
||||
<div class="arr-calendar-empty">
|
||||
Configure at least one of <code>SONARR_KEY</code>, <code>RADARR_KEY</code>,
|
||||
or <code>LIDARR_KEY</code> in the container environment to show releases.
|
||||
</div>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user