@joewinke/jatui 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -1
- package/dist/actions/tooltip.d.ts +7 -0
- package/dist/actions/tooltip.js +28 -0
- package/dist/ambience/index.d.ts +2 -2
- package/dist/ambience/index.js +2 -2
- package/dist/ambience/scapes/playlist.d.ts +31 -0
- package/dist/ambience/scapes/playlist.js +82 -1
- package/dist/ambience/shared.d.ts +33 -2
- package/dist/ambience/shared.js +32 -5
- package/dist/components/AmbienceToggle.svelte +203 -3
- package/dist/components/AmbienceToggle.svelte.d.ts +11 -1
- package/dist/components/stats/StatusBanner.svelte +119 -0
- package/dist/components/stats/StatusBanner.svelte.d.ts +13 -0
- package/dist/components/stats/StatusCard.svelte +176 -0
- package/dist/components/stats/StatusCard.svelte.d.ts +18 -0
- package/dist/components/stats/UptimeBar.svelte +254 -0
- package/dist/components/stats/UptimeBar.svelte.d.ts +20 -0
- package/dist/components/stats/uptime.d.ts +105 -0
- package/dist/components/stats/uptime.js +213 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/package.json +1 -1
|
@@ -32,6 +32,16 @@
|
|
|
32
32
|
* focus) get the play/pause control only. The transport joins the
|
|
33
33
|
* menu's roving order; a paused row stays visible (its play button is
|
|
34
34
|
* the resume) and a stopped one hides.
|
|
35
|
+
* - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
|
|
36
|
+
* public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
|
|
37
|
+
* marks the track `downloadable: true`) also get a Download button
|
|
38
|
+
* next to the transport: the track url is fetched to a Blob (a plain
|
|
39
|
+
* cross-origin `<a download>` is ignored), saved under
|
|
40
|
+
* "Composer - Title.ext" so attribution travels with the file, and a
|
|
41
|
+
* CORS failure falls back to opening the url in a new tab. The busy
|
|
42
|
+
* spinner never blocks playback. Shared mode: a remote tab downloads
|
|
43
|
+
* the leader's published track url (`SharedAmbienceState.trackDownload`)
|
|
44
|
+
* — or hides the button when the leader published none.
|
|
35
45
|
* - A playing playlist shows its current track's attribution (title,
|
|
36
46
|
* composer, license) under the playlist item and its section row.
|
|
37
47
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -233,8 +243,18 @@
|
|
|
233
243
|
import { getMotionContext } from '../motion/level';
|
|
234
244
|
import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
|
|
235
245
|
import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
|
|
246
|
+
import {
|
|
247
|
+
isTrackDownloadable,
|
|
248
|
+
trackDownloadFilename
|
|
249
|
+
} from '../ambience/scapes/playlist';
|
|
250
|
+
import IconButton from './IconButton.svelte';
|
|
251
|
+
import { safeSharedTrackDownload } from '../ambience/shared';
|
|
252
|
+
import type {
|
|
253
|
+
SharedAmbience,
|
|
254
|
+
SharedAmbienceState,
|
|
255
|
+
SharedTrackDownload
|
|
256
|
+
} from '../ambience/shared';
|
|
236
257
|
import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
|
|
237
|
-
import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
|
|
238
258
|
|
|
239
259
|
let {
|
|
240
260
|
engine,
|
|
@@ -252,6 +272,8 @@
|
|
|
252
272
|
const menuId = $props.id();
|
|
253
273
|
const hintId = `${menuId}-hint`;
|
|
254
274
|
const subId = `${menuId}-sub`;
|
|
275
|
+
/** The download button's licence/source description line (jatui-0vxcu). */
|
|
276
|
+
const downloadHintId = `${menuId}-dl`;
|
|
255
277
|
|
|
256
278
|
/** Long-press duration for the pointer path to the menu. */
|
|
257
279
|
const LONG_PRESS_MS = 450;
|
|
@@ -309,6 +331,14 @@
|
|
|
309
331
|
let sharedTitle = $state<string | null>(null);
|
|
310
332
|
let sharedPosition = $state<number | null>(null);
|
|
311
333
|
let sharedDuration = $state<number | null>(null);
|
|
334
|
+
/** The leader's published download info (jatui-0vxcu) — a remote tab's
|
|
335
|
+
* download button reads this; null hides the button (not published, or
|
|
336
|
+
* the licence forbids redistribution). */
|
|
337
|
+
let sharedDownload = $state<SharedTrackDownload | null>(null);
|
|
338
|
+
/** True while a download fetch is in flight — the button shows its busy
|
|
339
|
+
* spinner and refuses re-entry. The fetch runs beside playback; nothing
|
|
340
|
+
* here touches the engine. */
|
|
341
|
+
let downloadBusy = $state(false);
|
|
312
342
|
|
|
313
343
|
// Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
|
|
314
344
|
// the pending partial notch between wheel events so trackpad pixel deltas
|
|
@@ -360,6 +390,46 @@
|
|
|
360
390
|
);
|
|
361
391
|
const npPercent = $derived(progressPercent(npPosition, npDuration));
|
|
362
392
|
|
|
393
|
+
// --- download button (jatui-0vxcu) ---------------------------------------
|
|
394
|
+
/** What the download button offers right now: the playing (or paused)
|
|
395
|
+
* playlist track's url + ready-made filename + licence, gated by the
|
|
396
|
+
* licence AND by `safeAmbienceHref` — the url has to be an absolute
|
|
397
|
+
* http(s) one, because it is fetched and (on CORS failure) opened in a
|
|
398
|
+
* new tab; a `javascript:`/`data:`/scheme-relative entry means no button.
|
|
399
|
+
* On a remote tab the leader's publication is re-validated the same way
|
|
400
|
+
* (the leader is another tab, not a trusted source) — null → no button.
|
|
401
|
+
* Generative scapes never have one. */
|
|
402
|
+
const currentDownload = $derived.by(() => {
|
|
403
|
+
if (!showNowPlaying) return null;
|
|
404
|
+
if (isRemote) return safeSharedTrackDownload(sharedDownload);
|
|
405
|
+
const t = currentIsPlaylist ? (playlist?.track ?? null) : null;
|
|
406
|
+
if (!t || !isTrackDownloadable(t)) return null;
|
|
407
|
+
const url = safeAmbienceHref(t.url);
|
|
408
|
+
if (!url) return null;
|
|
409
|
+
return {
|
|
410
|
+
url,
|
|
411
|
+
filename: trackDownloadFilename(t),
|
|
412
|
+
license: t.license,
|
|
413
|
+
sourceUrl: t.sourceUrl
|
|
414
|
+
} satisfies SharedTrackDownload;
|
|
415
|
+
});
|
|
416
|
+
/** The button's tooltip: licence + source link — CC-BY needs attribution. */
|
|
417
|
+
const downloadTooltip = $derived(
|
|
418
|
+
currentDownload
|
|
419
|
+
? `Download ${currentDownload.filename} · ${currentDownload.license}${
|
|
420
|
+
currentDownload.sourceUrl ? ` · source: ${currentDownload.sourceUrl}` : ''
|
|
421
|
+
}`
|
|
422
|
+
: ''
|
|
423
|
+
);
|
|
424
|
+
/** The sr-only line the button's aria-describedby points at. */
|
|
425
|
+
const downloadHint = $derived(
|
|
426
|
+
currentDownload
|
|
427
|
+
? `Download as ${currentDownload.filename}. Licence: ${currentDownload.license}.${
|
|
428
|
+
currentDownload.sourceUrl ? ` Source: ${currentDownload.sourceUrl}.` : ''
|
|
429
|
+
}`
|
|
430
|
+
: ''
|
|
431
|
+
);
|
|
432
|
+
|
|
363
433
|
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
364
434
|
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
365
435
|
|
|
@@ -399,6 +469,7 @@
|
|
|
399
469
|
sharedTitle = s.trackTitle;
|
|
400
470
|
sharedPosition = s.trackPosition;
|
|
401
471
|
sharedDuration = s.trackDuration;
|
|
472
|
+
sharedDownload = s.trackDownload ?? null;
|
|
402
473
|
});
|
|
403
474
|
});
|
|
404
475
|
|
|
@@ -416,12 +487,25 @@
|
|
|
416
487
|
return item.subscribe((s) => {
|
|
417
488
|
playlist = s;
|
|
418
489
|
track = s.status === 'playing' ? s.track : null;
|
|
490
|
+
// The download rides the same publication (jatui-0vxcu): only a track
|
|
491
|
+
// whose licence permits redistribution AND whose url survives
|
|
492
|
+
// safeAmbienceHref (absolute http/https) is offered to the other tabs.
|
|
493
|
+
const t = s.status === 'playing' ? s.track : null;
|
|
494
|
+
const dlUrl = t && isTrackDownloadable(t) ? safeAmbienceHref(t.url) : null;
|
|
419
495
|
engine.setNowPlaying?.(
|
|
420
|
-
s.status === 'playing' &&
|
|
496
|
+
s.status === 'playing' && t ? nowPlayingLine(t) : null,
|
|
421
497
|
s.index,
|
|
422
498
|
s.status === 'playing' ? s.position : null,
|
|
423
499
|
s.status === 'playing' ? s.duration : null,
|
|
424
|
-
s.track?.title ?? null
|
|
500
|
+
s.track?.title ?? null,
|
|
501
|
+
t && dlUrl
|
|
502
|
+
? {
|
|
503
|
+
url: dlUrl,
|
|
504
|
+
filename: trackDownloadFilename(t),
|
|
505
|
+
license: t.license,
|
|
506
|
+
...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
|
|
507
|
+
}
|
|
508
|
+
: null
|
|
425
509
|
);
|
|
426
510
|
});
|
|
427
511
|
});
|
|
@@ -682,6 +766,47 @@
|
|
|
682
766
|
if (isPlaylistScape(currentItem)) currentItem.skip();
|
|
683
767
|
}
|
|
684
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The download button (jatui-0vxcu). A cross-origin `<a download>` is
|
|
771
|
+
* ignored by browsers, so the file is fetched to a Blob and saved through
|
|
772
|
+
* an object URL — the attribution filename was built by the pure helper.
|
|
773
|
+
* A failed fetch (CORS) falls back to opening the url in a new tab. Both
|
|
774
|
+
* the fetch and the fallback go through `safeAmbienceHref` — a url that
|
|
775
|
+
* is not absolute http(s) (a hostile manifest, or a hostile shared-mode
|
|
776
|
+
* leader) never reaches `window.open`. The fetch runs beside the engine —
|
|
777
|
+
* playback is never blocked or touched.
|
|
778
|
+
*/
|
|
779
|
+
async function onDownloadClick() {
|
|
780
|
+
const info = currentDownload;
|
|
781
|
+
const href = safeAmbienceHref(info?.url);
|
|
782
|
+
if (!info || !href || downloadBusy) return;
|
|
783
|
+
downloadBusy = true;
|
|
784
|
+
try {
|
|
785
|
+
const res = await fetch(href);
|
|
786
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
787
|
+
const blob = await res.blob();
|
|
788
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
789
|
+
const a = document.createElement('a');
|
|
790
|
+
a.href = objectUrl;
|
|
791
|
+
a.download = info.filename;
|
|
792
|
+
document.body.appendChild(a);
|
|
793
|
+
a.click();
|
|
794
|
+
a.remove();
|
|
795
|
+
// The browser has the blob reference the moment it accepts the
|
|
796
|
+
// download; the revoke is belt-and-braces housekeeping.
|
|
797
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
|
798
|
+
} catch {
|
|
799
|
+
// CORS refused or the network failed — the url still opens.
|
|
800
|
+
try {
|
|
801
|
+
window.open(href, '_blank', 'noopener,noreferrer');
|
|
802
|
+
} catch {
|
|
803
|
+
/* a blocked popup is not worth crashing over */
|
|
804
|
+
}
|
|
805
|
+
} finally {
|
|
806
|
+
downloadBusy = false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
685
810
|
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
686
811
|
// While the menu (or one of its submenus) is open the wheel adjusts volume
|
|
687
812
|
// instead of scrolling the page — EXCEPT over a scrollable list that can
|
|
@@ -1064,7 +1189,67 @@
|
|
|
1064
1189
|
</svg>
|
|
1065
1190
|
</button>
|
|
1066
1191
|
{/if}
|
|
1192
|
+
{#if currentDownload}
|
|
1193
|
+
<!-- Download (jatui-0vxcu): only for tracks whose licence
|
|
1194
|
+
permits redistribution (or the leader's published url
|
|
1195
|
+
on a remote tab). aria-describedby points at the
|
|
1196
|
+
licence line below — the tooltip's own action also
|
|
1197
|
+
rewrites describedby while it shows, so the line stays
|
|
1198
|
+
in the row as readable content either way. -->
|
|
1199
|
+
<IconButton
|
|
1200
|
+
label="Download {npTitle}"
|
|
1201
|
+
variant="ghost"
|
|
1202
|
+
size="sm"
|
|
1203
|
+
shape="circle"
|
|
1204
|
+
tooltip={downloadTooltip}
|
|
1205
|
+
tooltipPlacement="top"
|
|
1206
|
+
aria-describedby={downloadHintId}
|
|
1207
|
+
tabindex={-1}
|
|
1208
|
+
data-ambience-nav
|
|
1209
|
+
disabled={downloadBusy}
|
|
1210
|
+
onclick={onDownloadClick}
|
|
1211
|
+
>
|
|
1212
|
+
{#if downloadBusy}
|
|
1213
|
+
<!-- busy: the fetch is in flight (playback unaffected) -->
|
|
1214
|
+
<svg
|
|
1215
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1216
|
+
viewBox="0 0 24 24"
|
|
1217
|
+
fill="none"
|
|
1218
|
+
stroke="currentColor"
|
|
1219
|
+
stroke-width="2"
|
|
1220
|
+
stroke-linecap="round"
|
|
1221
|
+
class="jatui-dl-spin size-4"
|
|
1222
|
+
aria-hidden="true"
|
|
1223
|
+
>
|
|
1224
|
+
<circle cx="12" cy="12" r="9" opacity="0.25" />
|
|
1225
|
+
<path d="M21 12a9 9 0 0 0-9-9" />
|
|
1226
|
+
</svg>
|
|
1227
|
+
{:else}
|
|
1228
|
+
<!-- download, lucide geometry -->
|
|
1229
|
+
<svg
|
|
1230
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1231
|
+
viewBox="0 0 24 24"
|
|
1232
|
+
fill="none"
|
|
1233
|
+
stroke="currentColor"
|
|
1234
|
+
stroke-width="2"
|
|
1235
|
+
stroke-linecap="round"
|
|
1236
|
+
stroke-linejoin="round"
|
|
1237
|
+
class="size-4"
|
|
1238
|
+
aria-hidden="true"
|
|
1239
|
+
>
|
|
1240
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
1241
|
+
<polyline points="7 10 12 15 17 10" />
|
|
1242
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
1243
|
+
</svg>
|
|
1244
|
+
{/if}
|
|
1245
|
+
</IconButton>
|
|
1246
|
+
{/if}
|
|
1067
1247
|
</div>
|
|
1248
|
+
{#if currentDownload}
|
|
1249
|
+
<!-- The licence + source the download carries — CC-BY needs
|
|
1250
|
+
attribution, so the filename alone is not enough. -->
|
|
1251
|
+
<p id={downloadHintId} class="sr-only">{downloadHint}</p>
|
|
1252
|
+
{/if}
|
|
1068
1253
|
</div>
|
|
1069
1254
|
{/if}
|
|
1070
1255
|
|
|
@@ -1315,5 +1500,20 @@
|
|
|
1315
1500
|
.now-progress-fill {
|
|
1316
1501
|
transition: none;
|
|
1317
1502
|
}
|
|
1503
|
+
.jatui-dl-spin {
|
|
1504
|
+
animation: none;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/* The download button's busy spinner — task feedback, not ambient motion,
|
|
1509
|
+
* but it still holds still under reduced motion. */
|
|
1510
|
+
.jatui-dl-spin {
|
|
1511
|
+
animation: jatui-dl-rotate 900ms linear infinite;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
@keyframes jatui-dl-rotate {
|
|
1515
|
+
to {
|
|
1516
|
+
transform: rotate(360deg);
|
|
1517
|
+
}
|
|
1318
1518
|
}
|
|
1319
1519
|
</style>
|
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
* focus) get the play/pause control only. The transport joins the
|
|
32
32
|
* menu's roving order; a paused row stays visible (its play button is
|
|
33
33
|
* the resume) and a stopped one hides.
|
|
34
|
+
* - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
|
|
35
|
+
* public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
|
|
36
|
+
* marks the track `downloadable: true`) also get a Download button
|
|
37
|
+
* next to the transport: the track url is fetched to a Blob (a plain
|
|
38
|
+
* cross-origin `<a download>` is ignored), saved under
|
|
39
|
+
* "Composer - Title.ext" so attribution travels with the file, and a
|
|
40
|
+
* CORS failure falls back to opening the url in a new tab. The busy
|
|
41
|
+
* spinner never blocks playback. Shared mode: a remote tab downloads
|
|
42
|
+
* the leader's published track url (`SharedAmbienceState.trackDownload`)
|
|
43
|
+
* — or hides the button when the leader published none.
|
|
34
44
|
* - A playing playlist shows its current track's attribution (title,
|
|
35
45
|
* composer, license) under the playlist item and its section row.
|
|
36
46
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -141,8 +151,8 @@ export declare function wheelVolumeSteps(acc: {
|
|
|
141
151
|
* slider itself would produce for the same value.
|
|
142
152
|
*/
|
|
143
153
|
export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
|
|
144
|
-
import type { Ambience } from '../ambience/types';
|
|
145
154
|
import type { SharedAmbience } from '../ambience/shared';
|
|
155
|
+
import type { Ambience } from '../ambience/types';
|
|
146
156
|
type $$ComponentProps = {
|
|
147
157
|
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
|
|
148
158
|
engine: Ambience | SharedAmbience;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
StatusBanner: the overall status page headline (jatui-i3vxo). "All systems
|
|
3
|
+
operational" / degraded / outage, token colours, check / warning / x icon.
|
|
4
|
+
Token-only colours: the tint comes from the status tokens, never a hex.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts" module>
|
|
7
|
+
export type BannerStatus = 'operational' | 'degraded' | 'outage';
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import { tooltip } from '../../actions/tooltip';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** 'operational' | 'degraded' | 'outage' (see aggregateStatus for a pure helper). */
|
|
15
|
+
status: BannerStatus;
|
|
16
|
+
/** Override the message. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** Extra detail behind an (i) info button. */
|
|
19
|
+
info?: string;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { status, message, info, class: className = '' }: Props = $props();
|
|
24
|
+
|
|
25
|
+
const text = $derived(
|
|
26
|
+
message ?? (status === 'operational' ? 'All systems operational' : status === 'degraded' ? 'Some systems degraded' : 'Partial outage')
|
|
27
|
+
);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="jatui-status-banner {className}" role="status" data-status={status}>
|
|
31
|
+
{#if status === 'operational'}
|
|
32
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
33
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
34
|
+
<path d="M5.2 8.2 7.1 10l3.7-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
35
|
+
</svg>
|
|
36
|
+
{:else if status === 'outage'}
|
|
37
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
38
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
39
|
+
<path d="M5.8 5.8l4.4 4.4M10.2 5.8 5.8 10.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
40
|
+
</svg>
|
|
41
|
+
{:else}
|
|
42
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
43
|
+
<path d="M8 2 14.5 13h-13Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
|
44
|
+
<path d="M8 6.4v3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
45
|
+
<circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
|
|
46
|
+
</svg>
|
|
47
|
+
{/if}
|
|
48
|
+
<span class="sb-text">{text}</span>
|
|
49
|
+
{#if info}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="sb-info"
|
|
53
|
+
aria-label="More about this status"
|
|
54
|
+
use:tooltip={{ text: info, placement: 'top', delayMs: 0 }}
|
|
55
|
+
>
|
|
56
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
57
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
58
|
+
<path d="M8 7.2v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
59
|
+
<circle cx="8" cy="4.8" r="0.9" fill="currentColor" />
|
|
60
|
+
</svg>
|
|
61
|
+
</button>
|
|
62
|
+
{/if}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.jatui-status-banner {
|
|
67
|
+
--_st: var(--jatui-status-positive, var(--color-success));
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: var(--jatui-space-base, 8px);
|
|
71
|
+
padding: var(--jatui-space-base, 8px) var(--jatui-space-lg, 16px);
|
|
72
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
73
|
+
background: color-mix(in oklch, var(--_st) 12%, var(--color-base-100));
|
|
74
|
+
border: 1px solid color-mix(in oklch, var(--_st) 30%, transparent);
|
|
75
|
+
color: var(--_st);
|
|
76
|
+
font-size: 0.9375rem;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
}
|
|
79
|
+
.jatui-status-banner[data-status='degraded'] {
|
|
80
|
+
--_st: var(--jatui-status-caution, var(--color-warning));
|
|
81
|
+
}
|
|
82
|
+
.jatui-status-banner[data-status='outage'] {
|
|
83
|
+
--_st: var(--jatui-status-negative, var(--color-error));
|
|
84
|
+
}
|
|
85
|
+
.jatui-status-banner svg {
|
|
86
|
+
width: 16px;
|
|
87
|
+
height: 16px;
|
|
88
|
+
flex: none;
|
|
89
|
+
}
|
|
90
|
+
.sb-text {
|
|
91
|
+
flex: 1;
|
|
92
|
+
min-width: 0;
|
|
93
|
+
}
|
|
94
|
+
.sb-info {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
width: 24px;
|
|
99
|
+
height: 24px;
|
|
100
|
+
padding: 0;
|
|
101
|
+
border: 0;
|
|
102
|
+
background: transparent;
|
|
103
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
104
|
+
color: inherit;
|
|
105
|
+
opacity: 0.75;
|
|
106
|
+
cursor: help;
|
|
107
|
+
}
|
|
108
|
+
.sb-info svg {
|
|
109
|
+
width: 14px;
|
|
110
|
+
height: 14px;
|
|
111
|
+
}
|
|
112
|
+
.sb-info:hover {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
}
|
|
115
|
+
.sb-info:focus-visible {
|
|
116
|
+
outline: 2px solid var(--color-primary);
|
|
117
|
+
outline-offset: 1px;
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type BannerStatus = 'operational' | 'degraded' | 'outage';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** 'operational' | 'degraded' | 'outage' (see aggregateStatus for a pure helper). */
|
|
4
|
+
status: BannerStatus;
|
|
5
|
+
/** Override the message. */
|
|
6
|
+
message?: string;
|
|
7
|
+
/** Extra detail behind an (i) info button. */
|
|
8
|
+
info?: string;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const StatusBanner: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type StatusBanner = ReturnType<typeof StatusBanner>;
|
|
13
|
+
export default StatusBanner;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
StatusCard: one measured component on a status page (jatui-i3vxo). Name +
|
|
3
|
+
info tooltip (what is measured), the status icon at the right, the
|
|
4
|
+
UptimeBar strip, and the status word (Normal / Degraded / Outage / …).
|
|
5
|
+
Data in: `{ date, status, downMinutes?, note? }[]`. Composes with
|
|
6
|
+
StatusBanner on top of a status page grid.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { tooltip } from '../../actions/tooltip';
|
|
10
|
+
import { buildUptime, formatUptimePct, uptimeWord, type UptimeDayInput } from './uptime';
|
|
11
|
+
import UptimeBar from './UptimeBar.svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Name of the measured thing (e.g. "API"). */
|
|
15
|
+
name: string;
|
|
16
|
+
/** What is measured, shown behind an (i) info button. */
|
|
17
|
+
description?: string;
|
|
18
|
+
data: readonly UptimeDayInput[];
|
|
19
|
+
/** Window length in days. Default 90. */
|
|
20
|
+
days?: number;
|
|
21
|
+
end?: string | Date;
|
|
22
|
+
/** Override the status word. */
|
|
23
|
+
statusWord?: string;
|
|
24
|
+
locale?: string;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
name,
|
|
30
|
+
description,
|
|
31
|
+
data,
|
|
32
|
+
days = 90,
|
|
33
|
+
end,
|
|
34
|
+
statusWord,
|
|
35
|
+
locale,
|
|
36
|
+
class: className = ''
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
const layout = $derived(buildUptime({ data, days, end }));
|
|
40
|
+
const word = $derived(statusWord ?? uptimeWord(layout.worst));
|
|
41
|
+
|
|
42
|
+
const totalDown = $derived(layout.bars.reduce((s, b) => s + b.downMinutes, 0));
|
|
43
|
+
// Truncating formatter — never overclaims (see formatUptimePct).
|
|
44
|
+
const uptimeText = $derived(
|
|
45
|
+
layout.uptimePct == null
|
|
46
|
+
? 'No data'
|
|
47
|
+
: `${formatUptimePct(layout.uptimePct, locale, totalDown)} uptime`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** "operational" | "degraded" | "partial" | "outage" | "no-data" — for the tone classes. */
|
|
51
|
+
const heading = $derived(layout.worst);
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<section class="jatui-status-card st-{heading} {className}" aria-label={name}>
|
|
55
|
+
<header class="sc-head">
|
|
56
|
+
<h3 class="sc-name">
|
|
57
|
+
<span>{name}</span>
|
|
58
|
+
{#if description}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="sc-info"
|
|
62
|
+
aria-label={`About ${name}`}
|
|
63
|
+
use:tooltip={{ text: description, placement: 'top', delayMs: 0 }}
|
|
64
|
+
>
|
|
65
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
66
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
67
|
+
<path d="M8 7.2v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
68
|
+
<circle cx="8" cy="4.8" r="0.9" fill="currentColor" />
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
{/if}
|
|
72
|
+
</h3>
|
|
73
|
+
<span class="sc-status">
|
|
74
|
+
{#if heading === 'operational'}
|
|
75
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
76
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
77
|
+
<path d="M5.2 8.2 7.1 10l3.7-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
78
|
+
</svg>
|
|
79
|
+
{:else if heading === 'outage'}
|
|
80
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
81
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
82
|
+
<path d="M5.8 5.8l4.4 4.4M10.2 5.8 5.8 10.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
83
|
+
</svg>
|
|
84
|
+
{:else if heading === 'no-data'}
|
|
85
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
86
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
87
|
+
<path d="M5.6 8h4.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
88
|
+
</svg>
|
|
89
|
+
{:else}
|
|
90
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
91
|
+
<path d="M8 2 14.5 13h-13Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
|
92
|
+
<path d="M8 6.4v3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
93
|
+
<circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
|
|
94
|
+
</svg>
|
|
95
|
+
{/if}
|
|
96
|
+
<span class="sc-word">{word}</span>
|
|
97
|
+
</span>
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
<UptimeBar {data} {days} {end} {locale} label={name} />
|
|
101
|
+
|
|
102
|
+
<!-- Screen-reader text per bar group: the strip's aria-label already
|
|
103
|
+
carries the summary; this paragraph is the visible fallback for the
|
|
104
|
+
heading structure. -->
|
|
105
|
+
<p class="sr-only">{name}: {word}, {uptimeText}</p>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
.jatui-status-card {
|
|
110
|
+
--_st: var(--jatui-status-positive, var(--color-success));
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: var(--jatui-space-base, 8px);
|
|
114
|
+
padding: var(--jatui-space-lg, 16px);
|
|
115
|
+
border: 1px solid var(--jatui-separator, color-mix(in oklch, var(--color-base-content) 12%, transparent));
|
|
116
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
117
|
+
background: var(--color-base-100);
|
|
118
|
+
}
|
|
119
|
+
.st-operational { --_st: var(--jatui-status-positive, var(--color-success)); }
|
|
120
|
+
.st-degraded { --_st: var(--jatui-status-caution, var(--color-warning)); }
|
|
121
|
+
.st-partial { --_st: color-mix(in oklch, var(--color-warning) 55%, var(--color-error)); }
|
|
122
|
+
.st-outage { --_st: var(--jatui-status-negative, var(--color-error)); }
|
|
123
|
+
.st-no-data { --_st: var(--jatui-status-neutral, color-mix(in oklab, var(--color-base-content) 40%, transparent)); }
|
|
124
|
+
|
|
125
|
+
.sc-head {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: space-between;
|
|
129
|
+
gap: var(--jatui-space-base, 8px);
|
|
130
|
+
}
|
|
131
|
+
.sc-name {
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: var(--jatui-space-sm, 4px);
|
|
135
|
+
margin: 0;
|
|
136
|
+
font-size: 0.9375rem;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
color: var(--color-base-content);
|
|
139
|
+
}
|
|
140
|
+
.sc-info {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
width: 24px;
|
|
145
|
+
height: 24px;
|
|
146
|
+
padding: 0;
|
|
147
|
+
border: 0;
|
|
148
|
+
background: transparent;
|
|
149
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
150
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
151
|
+
cursor: help;
|
|
152
|
+
}
|
|
153
|
+
.sc-info svg {
|
|
154
|
+
width: 14px;
|
|
155
|
+
height: 14px;
|
|
156
|
+
}
|
|
157
|
+
.sc-info:hover {
|
|
158
|
+
color: var(--color-base-content);
|
|
159
|
+
}
|
|
160
|
+
.sc-info:focus-visible {
|
|
161
|
+
outline: 2px solid var(--color-primary);
|
|
162
|
+
outline-offset: 1px;
|
|
163
|
+
}
|
|
164
|
+
.sc-status {
|
|
165
|
+
display: inline-flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: var(--jatui-space-sm, 4px);
|
|
168
|
+
color: var(--_st);
|
|
169
|
+
font-size: 0.875rem;
|
|
170
|
+
font-weight: 500;
|
|
171
|
+
}
|
|
172
|
+
.sc-status svg {
|
|
173
|
+
width: 16px;
|
|
174
|
+
height: 16px;
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type UptimeDayInput } from './uptime';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Name of the measured thing (e.g. "API"). */
|
|
4
|
+
name: string;
|
|
5
|
+
/** What is measured, shown behind an (i) info button. */
|
|
6
|
+
description?: string;
|
|
7
|
+
data: readonly UptimeDayInput[];
|
|
8
|
+
/** Window length in days. Default 90. */
|
|
9
|
+
days?: number;
|
|
10
|
+
end?: string | Date;
|
|
11
|
+
/** Override the status word. */
|
|
12
|
+
statusWord?: string;
|
|
13
|
+
locale?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const StatusCard: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type StatusCard = ReturnType<typeof StatusCard>;
|
|
18
|
+
export default StatusCard;
|