feat(frontend): give failure one voice
There was no app-level notification surface: two components had grown private toasts and the other 84 catch blocks ended at console.error, so a user with a moved file, a locked database or an offline network saw a button that did nothing. Where errors did surface, eight sites printed the raw Go string. Four levels, chosen by the call site from one rule — a failure is only worth interrupting for if the user can do something about it that they are not already doing: Blocking (data at risk), Persistent (something asked for that did not happen, worth retrying), Transient (a small action whose state visibly reverted anyway), Inline (rendered in the panel that failed). Three things about it are load-bearing. Coalescing lives in the store, keyed by (level, region, key) within a window, so 200 unplayable files are one message with a count and no future caller has to remember that. An inline notification carries a *region*, because "inline" says not global, not where. And the bottom band belongs to the player, so the app-level stack sits under the header — the player's own floating notice grows upward by however many lines it needs. `utils/describe-error.ts` maps the causes a user can act on to copy; `explainError` repeats a backend message when it is one of our own sentinels rather than a Go wrapping chain, since mapping "a library with that name already exists" to something generic is a regression. `confirmAction()` is a wa-dialog, so destructive actions inherit the focus trap and Escape the hand-rolled overlays do not have.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* "Are you sure?", once.
|
||||
*
|
||||
* Three destructive actions had no confirmation at all: deleting
|
||||
* playlists (including a multi-select loop that deleted N of them),
|
||||
* removing a durable download request, and removing a download client
|
||||
* with its stored credentials (errors.M6, M7, m4).
|
||||
*
|
||||
* The shape is the one the codebase already uses twice — say what will
|
||||
* happen *before* asking (`config-page`'s removal impact), then ask —
|
||||
* so this is that shape written once rather than a third pattern. It is
|
||||
* a `wa-dialog`, which brings the focus trap, Escape and focus restore
|
||||
* that the hand-rolled overlays do not have.
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, query, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
export interface ConfirmRequest {
|
||||
title: string;
|
||||
/** What is about to happen, in the user's terms. */
|
||||
message: string;
|
||||
/** The consequence, when it is worth spelling out separately. */
|
||||
impact?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Styles the confirm button as destructive. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
@customElement('confirm-dialog')
|
||||
export class ConfirmDialog extends LitElement {
|
||||
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
|
||||
|
||||
@state() private request: ConfirmRequest | null = null;
|
||||
|
||||
private settle: ((ok: boolean) => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.impact {
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--yj-danger, #e03131);
|
||||
border-color: var(--yj-danger, #e03131);
|
||||
color: #fff;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/** Ask. Resolves true if the user went ahead. */
|
||||
ask(request: ConfirmRequest): Promise<boolean> {
|
||||
this.close(false);
|
||||
this.request = request;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.settle = resolve;
|
||||
void this.updateComplete.then(() => {
|
||||
if (this.dialog) this.dialog.open = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private close(ok: boolean): void {
|
||||
const settle = this.settle;
|
||||
|
||||
this.settle = null;
|
||||
|
||||
if (this.dialog) this.dialog.open = false;
|
||||
this.request = null;
|
||||
settle?.(ok);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const request = this.request;
|
||||
|
||||
if (!request) return nothing;
|
||||
|
||||
return html`
|
||||
<wa-dialog
|
||||
label=${request.title}
|
||||
data-testid="confirm-dialog"
|
||||
@wa-hide=${() => this.close(false)}
|
||||
>
|
||||
<p>${request.message}</p>
|
||||
${request.impact
|
||||
? html`<p class="impact">${request.impact}</p>`
|
||||
: nothing}
|
||||
<div class="actions" slot="footer">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-cancel"
|
||||
@click=${() => this.close(false)}
|
||||
>
|
||||
${request.cancelLabel ?? 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=${request.danger ? 'danger' : ''}
|
||||
data-testid="confirm-accept"
|
||||
@click=${() => this.close(true)}
|
||||
>
|
||||
${request.confirmLabel ?? 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The one instance, created on first use and reused after. */
|
||||
let host: ConfirmDialog | null = null;
|
||||
|
||||
/**
|
||||
* Ask the user to confirm something destructive.
|
||||
*
|
||||
* Call sites do not mount anything: the dialog attaches itself to the
|
||||
* document the first time it is needed, which keeps a confirmation from
|
||||
* being skipped because the host forgot to render it.
|
||||
*/
|
||||
export function confirmAction(request: ConfirmRequest): Promise<boolean> {
|
||||
if (!host) {
|
||||
host = document.createElement('confirm-dialog') as ConfirmDialog;
|
||||
document.body.append(host);
|
||||
}
|
||||
|
||||
return host.ask(request);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The fourth level: a failure rendered in the region that failed.
|
||||
*
|
||||
* A track that will not play, a search that did not answer, an index
|
||||
* whose status could not be read — these belong to one panel, and a
|
||||
* toast for them would be noise. The region names itself
|
||||
* (`<inline-notice region="player">`) and anything that raises
|
||||
* `notificationStore.inline('player', …)` lands here.
|
||||
*
|
||||
* `floating` is for a host with no room in its own layout: the bottom
|
||||
* bar is a fixed 4em grid row, so a message laid out inside it squeezes
|
||||
* the transport out of its own footer.
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
import { noticeStyles, renderNotice } from './notice';
|
||||
|
||||
@customElement('inline-notice')
|
||||
export class InlineNotice extends LitElement {
|
||||
/** Which region's messages to render. */
|
||||
@property({ type: String }) region = '';
|
||||
|
||||
/** Render above the host instead of in its flow. */
|
||||
@property({ type: Boolean }) floating = false;
|
||||
|
||||
/** Overrides `data-testid` on the notice, for hosts that already
|
||||
* have a named message element in their specs. */
|
||||
@property({ type: String, attribute: 'testid' }) testid = '';
|
||||
|
||||
@state() private version = 0;
|
||||
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
noticeStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Left-anchored and no wider than half the window: the
|
||||
app-level stack sits in the same band on the right, and
|
||||
a full-width strip overlapped it. */
|
||||
:host([floating]) {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 16px;
|
||||
right: auto;
|
||||
max-width: min(36em, 48vw);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
:host(:not([floating])) .notice {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.unsubscribe = notificationStore.subscribe(() => {
|
||||
this.version += 1;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
private handlers = {
|
||||
dismiss: (id: number) => notificationStore.dismiss(id),
|
||||
act: (id: number) => notificationStore.runAction(id),
|
||||
};
|
||||
|
||||
override render() {
|
||||
const items = notificationStore.forRegion(this.region);
|
||||
|
||||
if (items.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<div role="status" aria-live="polite">
|
||||
${items.map((n) =>
|
||||
renderNotice(n, this.handlers, this.testid || undefined),
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The one presentation, shared by the four levels.
|
||||
*
|
||||
* A notice looks the same wherever it appears — icon, sentence, an
|
||||
* optional action, a dismiss — and the level only decides *where* it is
|
||||
* rendered and how long it stays. Keeping the markup in one place is
|
||||
* what stops the next level from inventing a fifth look.
|
||||
*/
|
||||
import { css, html, nothing, type TemplateResult } from 'lit';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
import type { Notification, NotificationTone } from '@store/notification-store';
|
||||
|
||||
const TONE_ICONS: Record<NotificationTone, string> = {
|
||||
error: 'circle-exclamation',
|
||||
warning: 'triangle-exclamation',
|
||||
info: 'circle-info',
|
||||
success: 'circle-check',
|
||||
};
|
||||
|
||||
export const noticeStyles = css`
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-left: 3px solid var(--yj-warning, #e0a800);
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 40%);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.notice[data-tone='error'] {
|
||||
border-left-color: var(--yj-danger, #e03131);
|
||||
}
|
||||
|
||||
.notice[data-tone='info'] {
|
||||
border-left-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.notice[data-tone='success'] {
|
||||
border-left-color: var(--yj-success, #37b24d);
|
||||
}
|
||||
|
||||
.notice > wa-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--yj-warning, #e0a800);
|
||||
}
|
||||
|
||||
.notice[data-tone='error'] > wa-icon {
|
||||
color: var(--yj-danger, #e03131);
|
||||
}
|
||||
|
||||
.notice-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notice-action {
|
||||
background: none;
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
margin-top: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.notice-action:hover {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
}
|
||||
|
||||
.notice-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface NoticeHandlers {
|
||||
dismiss: (id: number) => void;
|
||||
act: (id: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One notice, in the shape every level shares.
|
||||
*
|
||||
* `testid` exists for hosts whose specs already name their message
|
||||
* element — the player bar's, which predates this surface.
|
||||
*/
|
||||
export function renderNotice(
|
||||
notification: Notification,
|
||||
handlers: NoticeHandlers,
|
||||
testid = 'notification',
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="notice"
|
||||
data-tone=${notification.tone}
|
||||
data-testid=${testid}
|
||||
data-level=${notification.level}
|
||||
role=${notification.tone === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
<wa-icon name=${TONE_ICONS[notification.tone]}></wa-icon>
|
||||
<div class="notice-body">
|
||||
${notification.title
|
||||
? html`<div class="notice-title">${notification.title}</div>`
|
||||
: nothing}
|
||||
<div class="notice-text">${notification.text}</div>
|
||||
${notification.action
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="notice-action"
|
||||
data-testid="notification-action"
|
||||
@click=${() => handlers.act(notification.id)}
|
||||
>
|
||||
${notification.action.label}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="notice-dismiss"
|
||||
aria-label="Dismiss message"
|
||||
@click=${() => handlers.dismiss(notification.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Where the app speaks: the three levels that are not tied to a panel.
|
||||
*
|
||||
* Mounted once, in `index.html`, next to the first-run wizard — it has
|
||||
* to outlive every view, since the failure it reports is usually the
|
||||
* reason the user is about to navigate somewhere else.
|
||||
*
|
||||
* `inline` is the fourth level and is not here: it belongs to the
|
||||
* region that failed (`<inline-notice region="…">`).
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
import { noticeStyles, renderNotice } from './notice';
|
||||
|
||||
@customElement('notification-host')
|
||||
export class NotificationHost extends LitElement {
|
||||
@state() private version = 0;
|
||||
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
noticeStyles,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Below the header, not above the player bar: the bottom
|
||||
band belongs to the player, whose own inline notice
|
||||
floats there and grows upward by however many lines it
|
||||
needs — at 800×600 a two-line one reached straight into
|
||||
a bottom-anchored stack. */
|
||||
.stack {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: calc(4em + 12px);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(30em, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stack .notice {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.blocking-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.blocking-actions button {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.blocking-actions .primary {
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.blocking-detail {
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
margin-top: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Not a cached view: this element is mounted once and never
|
||||
// navigated away from, so connection really is its lifetime.
|
||||
this.unsubscribe = notificationStore.subscribe(() => {
|
||||
this.version += 1;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
private handlers = {
|
||||
dismiss: (id: number) => notificationStore.dismiss(id),
|
||||
act: (id: number) => notificationStore.runAction(id),
|
||||
};
|
||||
|
||||
/** Blocking is one modal at a time, and it must be acknowledged. */
|
||||
private renderBlocking() {
|
||||
const notification = notificationStore.currentBlocking();
|
||||
|
||||
if (!notification) return nothing;
|
||||
|
||||
return html`
|
||||
<wa-dialog
|
||||
open
|
||||
label=${notification.title ?? 'Something needs your attention'}
|
||||
data-testid="notification-blocking"
|
||||
@wa-hide=${() => notificationStore.dismiss(notification.id)}
|
||||
>
|
||||
<p>${notification.text}</p>
|
||||
${notification.detail
|
||||
? html`<p class="blocking-detail">${notification.detail}</p>`
|
||||
: nothing}
|
||||
<div class="blocking-actions" slot="footer">
|
||||
${notification.action
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
data-testid="notification-action"
|
||||
@click=${() =>
|
||||
notificationStore.runAction(notification.id)}
|
||||
>
|
||||
${notification.action.label}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
@click=${() => notificationStore.dismiss(notification.id)}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const stacked = [
|
||||
...notificationStore.byLevel('persistent'),
|
||||
...notificationStore.byLevel('transient'),
|
||||
];
|
||||
|
||||
return html`
|
||||
${this.renderBlocking()}
|
||||
${stacked.length === 0
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="stack"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="notification-stack"
|
||||
>
|
||||
${stacked.map((n) => renderNotice(n, this.handlers))}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user