refactor(webui): unify destructive confirmations

This commit is contained in:
joshpatra committed 2026-07-27 05:49:49 -04:00
1 parent 73af3153a1
commit 2d8abc4dda
11 files changed
+119 -110

No files matched your search

+5
View File
@@ -308,6 +308,7 @@
background: var(--color-panel-raised);
color: var(--color-signal);
font-weight: 800;
text-decoration: none;
}
.avatar img {
@@ -839,6 +840,10 @@
margin: 0;
}
.sidebar nav a:nth-child(6) {
display: none;
}
.sidebar nav a {
min-width: 0;
min-height: 3.4rem;
@@ -1,7 +1,8 @@
<script lang="ts">
import { AlertDialog, Dialog } from "bits-ui";
import { Dialog } from "bits-ui";
import { sources, type ProviderAccount } from "$lib/api";
import { audienceLabel } from "$lib/sources";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
let {
open = $bindable(false),
@@ -133,21 +134,14 @@
</Dialog.Portal>
</Dialog.Root>
<AlertDialog.Root bind:open={confirmOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>Share this connection with {expansion === "library" ? "a library" : "everyone"}?</AlertDialog.Title>
<AlertDialog.Description>
{expansion === "library"
? `Every user with access to library ${libraryScopeId} may use this account's supported Source capabilities.`
: "Every Allstarr user will be allowed to use this account for its supported Source capabilities."}
Credentials remain hidden.
</AlertDialog.Description>
<footer>
<AlertDialog.Cancel class="button-secondary">Keep current access</AlertDialog.Cancel>
<AlertDialog.Action class="button-danger" onclick={() => void save()}>Share with {expansion === "library" ? "library" : "everyone"}</AlertDialog.Action>
</footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={confirmOpen}
title={`Share this connection with ${expansion === "library" ? "a library" : "everyone"}?`}
description={`${expansion === "library"
? `Every user with access to library ${libraryScopeId} may use this account's supported Source capabilities.`
: "Every Allstarr user will be allowed to use this account for its supported Source capabilities."} Credentials remain hidden.`}
confirmLabel={`Share with ${expansion === "library" ? "library" : "everyone"}`}
cancelLabel="Keep current access"
disabled={saving}
onConfirm={save}
/>
@@ -0,0 +1,37 @@
<script lang="ts">
import { AlertDialog } from "bits-ui";
let {
open = $bindable(false),
title,
description,
confirmLabel,
cancelLabel = "Cancel",
confirmClass = "button-danger",
disabled = false,
onConfirm,
}: {
open: boolean;
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
confirmClass?: string;
disabled?: boolean;
onConfirm: () => void | Promise<void>;
} = $props();
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>{title}</AlertDialog.Title>
<AlertDialog.Description>{description}</AlertDialog.Description>
<footer>
<AlertDialog.Cancel class="button-secondary" {disabled}>{cancelLabel}</AlertDialog.Cancel>
<AlertDialog.Action class={confirmClass} {disabled} onclick={() => void onConfirm()}>{confirmLabel}</AlertDialog.Action>
</footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
+10 -20
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertDialog } from "bits-ui";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import {
downloads,
home,
@@ -263,23 +263,13 @@
{/if}
</section>
<AlertDialog.Root bind:open={confirmOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>{removal?.kind === "all" ? `Remove all ${label.toLowerCase()} tracks?` : "Remove this track?"}</AlertDialog.Title>
<AlertDialog.Description>
{removal?.kind === "all"
? `This deletes every ${label.toLowerCase()} audio file and its lyrics sidecar.`
: "This deletes the managed audio file and its lyrics sidecar. This cannot be undone."}
</AlertDialog.Description>
<footer>
<AlertDialog.Cancel class="button-secondary">Cancel</AlertDialog.Cancel>
<AlertDialog.Action class="button-danger" onclick={() => void remove()}>
{removal?.kind === "all" ? "Remove all" : "Remove track"}
</AlertDialog.Action>
</footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={confirmOpen}
title={removal?.kind === "all" ? `Remove all ${label.toLowerCase()} tracks?` : "Remove this track?"}
description={removal?.kind === "all"
? `This deletes every ${label.toLowerCase()} audio file and its lyrics sidecar.`
: "This deletes the managed audio file and its lyrics sidecar. This cannot be undone."}
confirmLabel={removal?.kind === "all" ? "Remove all" : "Remove track"}
onConfirm={remove}
/>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { AlertDialog } from "bits-ui";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import { home, intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
let protocol = $state("jellyfin");
@@ -198,13 +198,14 @@
{/if}
</section>
<AlertDialog.Root bind:open={purgeOpen}>
<AlertDialog.Portal><AlertDialog.Overlay class="dialog-overlay" /><AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>Clear this library’s Intelligence data?</AlertDialog.Title>
<AlertDialog.Description>Retained signals, profiles, recommendations, feedback, and generated sets for this exact scope will be removed.</AlertDialog.Description>
<footer><AlertDialog.Cancel class="button-secondary">Keep my data</AlertDialog.Cancel><AlertDialog.Action class="button-danger" onclick={() => void perform("purge", () => intelligence.purge(scope))}>Turn off and clear</AlertDialog.Action></footer>
</AlertDialog.Content></AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={purgeOpen}
title="Clear this library’s Intelligence data?"
description="Retained signals, profiles, recommendations, feedback, and generated sets for this exact scope will be removed."
confirmLabel="Turn off and clear"
cancelLabel="Keep my data"
onConfirm={() => perform("purge", () => intelligence.purge(scope))}
/>
<style>
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.run-progress{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,.5fr) auto;align-items:center;gap:1rem;padding:1rem}.run-progress p{margin:0}.run-progress small{display:block;color:var(--color-ink-muted)}.run-progress progress{width:100%;accent-color:var(--color-signal)}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
+11 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertDialog, DropdownMenu } from "bits-ui";
import { DropdownMenu } from "bits-ui";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import {
home,
matchReview,
@@ -426,23 +427,13 @@
onReject={(match) => confirm("reject", match)}
/>
<AlertDialog.Root bind:open={destructiveOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>{destructive?.kind === "clear" ? "Clear manual review?" : "Reject this candidate?"}</AlertDialog.Title>
<AlertDialog.Description>
{destructive?.kind === "clear"
? "The durable manual decision will be revoked and automatic matching will become authoritative again."
: "The current candidate will be recorded as rejected. You can rematch it later."}
</AlertDialog.Description>
<footer>
<AlertDialog.Cancel class="button-secondary">Cancel</AlertDialog.Cancel>
<AlertDialog.Action class="button-danger" onclick={() => void applyDestructive()}>
{destructive?.kind === "clear" ? "Clear review" : "Reject candidate"}
</AlertDialog.Action>
</footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={destructiveOpen}
title={destructive?.kind === "clear" ? "Clear manual review?" : "Reject this candidate?"}
description={destructive?.kind === "clear"
? "The durable manual decision will be revoked and automatic matching will become authoritative again."
: "The current candidate will be recorded as rejected. You can rematch it later."}
confirmLabel={destructive?.kind === "clear" ? "Clear review" : "Reject candidate"}
onConfirm={applyDestructive}
/>
{/if}
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertDialog } from "bits-ui";
import { home, type JobResponse } from "$lib/api";
import { compactProgress, progressDetails } from "$lib/jobs";
import { liveUpdates } from "$lib/live-updates.svelte";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
let {
playlistName,
@@ -125,17 +125,13 @@
</div>
</details>
<AlertDialog.Root bind:open={cancelOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>Cancel this operation?</AlertDialog.Title>
<AlertDialog.Description>Completed durable work remains recorded. The worker will stop at its next safe cancellation point.</AlertDialog.Description>
<footer>
<AlertDialog.Cancel class="button-secondary">Keep running</AlertDialog.Cancel>
<AlertDialog.Action class="button-danger" disabled={cancelling} onclick={() => void cancel()}>Cancel operation</AlertDialog.Action>
</footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={cancelOpen}
title="Cancel this operation?"
description="Completed durable work remains recorded. The worker will stop at its next safe cancellation point."
confirmLabel="Cancel operation"
cancelLabel="Keep running"
disabled={cancelling}
onConfirm={cancel}
/>
{/if}
+8 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertDialog } from "bits-ui";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import {
home,
settings,
@@ -338,14 +338,11 @@
{/if}
</section>
<AlertDialog.Root bind:open={purgeOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>Purge the application cache?</AlertDialog.Title>
<AlertDialog.Description>Disposable metadata and media payloads will be removed. PostgreSQL business state, accounts, mappings, playlists, and kept audio are not affected.</AlertDialog.Description>
<footer><AlertDialog.Cancel class="button-secondary">Cancel</AlertDialog.Cancel><AlertDialog.Action class="button-danger" onclick={() => void purge()}>Purge cache</AlertDialog.Action></footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={purgeOpen}
title="Purge the application cache?"
description="Disposable metadata and media payloads will be removed. PostgreSQL business state, accounts, mappings, playlists, and kept audio are not affected."
confirmLabel="Purge cache"
onConfirm={purge}
/>
{/if}
+9 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { AlertDialog, DropdownMenu } from "bits-ui";
import { DropdownMenu } from "bits-ui";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import {
home,
sources,
@@ -384,19 +385,11 @@
<ConnectSourceDialog bind:open={configureOpen} {providers} {administrator} account={selectedAccount} onSaved={completed} />
<AccountAccessDialog bind:open={accessOpen} account={selectedAccount} users={audienceUsers} onSaved={completed} />
<AlertDialog.Root bind:open={removeOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="dialog-overlay" />
<AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>Remove this Source connection?</AlertDialog.Title>
<AlertDialog.Description>
The encrypted credential is revoked and this account can no longer route provider requests. Audit history remains.
</AlertDialog.Description>
<footer>
<AlertDialog.Cancel class="button-secondary">Cancel</AlertDialog.Cancel>
<AlertDialog.Action class="button-danger" onclick={() => void remove()}>Remove connection</AlertDialog.Action>
</footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<ConfirmDialog
bind:open={removeOpen}
title="Remove this Source connection?"
description="The encrypted credential is revoked and this account can no longer route provider requests. Audit history remains."
confirmLabel="Remove connection"
onConfirm={remove}
/>
{/if}
+2 -2
View File
@@ -260,7 +260,7 @@
</nav>
<div class="profile">
<div class="avatar">
<a class="avatar" href="#/settings" aria-label={`Settings for ${session.user?.name ?? "current user"}`}>
{#if session.user?.avatarUrl && !avatarFailed}
<img
src={session.user.avatarUrl}
@@ -272,7 +272,7 @@
{:else}
<span>{initials}</span>
{/if}
</div>
</a>
<div class="min-w-0">
<strong>{session.user?.name}</strong>
<small>{session.backend}</small>
+5
View File
@@ -1020,6 +1020,11 @@ test("Profile artwork is stable in full, slim, and mobile navigation", async ({
await expect(avatar).toBeVisible();
await expect(avatar.locator("img")).toBeVisible();
await expect.poll(async () => (await avatar.boundingBox())?.width ?? 0).toBe(40);
if (width === 390) {
await expect(page.getByRole("navigation", { name: "Primary" }).getByRole("link", { name: "Settings" })).toBeHidden();
await expect(avatar).toHaveAttribute("href", "#/settings");
await expect.poll(async () => (await page.locator(".sidebar").boundingBox())?.height ?? 0).toBeLessThan(80);
}
}
await page.route("**/api/admin/auth/me/avatar?user=user", (route) => route.fulfill({ status: 404 }));