Skip to content

Commit f7253b2

Browse files
authored
feat(ui): add toast notifications for user actions (#34)
* feat(ui): add toast notifications for user actions Add svelte-sonner with a globally themed toaster and use it for build promotion, webhook deletion, metadata saves, and clipboard failures. Improve clipboard status feedback and preserve inline webhook creation errors. * fix(ui): clean up clipboard reset timer Clear the pending clipboard status timeout when the component is destroyed to avoid updating state after unmount.
1 parent f9a0f87 commit f7253b2

9 files changed

Lines changed: 92 additions & 34 deletions

File tree

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"shadcn-svelte": "^1.5.0",
4343
"svelte": "^5.56.8",
4444
"svelte-check": "^4.7.5",
45+
"svelte-sonner": "^1.1.0",
4546
"svelte2tsx": "^0.7.60",
4647
"tailwind-merge": "^3.6.0",
4748
"tailwind-variants": "^3.3.1",

src/lib/components/PromoteBuildButton.svelte

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<script lang="ts">
22
import { Button } from "$lib/components/ui/button";
3-
import * as Alert from "$lib/components/ui/alert";
43
import * as AlertDialog from "$lib/components/ui/alert-dialog";
54
import { getContextClient } from "@urql/svelte";
65
import { graphql } from "$lib/gql";
76
import { getOperationErrorMessage, getUnexpectedOperationResultMessage } from "$lib/operation-error";
87
import { page } from "$app/state";
8+
import { toast } from "svelte-sonner";
99
1010
interface Props {
1111
buildNumber: number;
@@ -17,12 +17,10 @@
1717
1818
let promoting: boolean = $state(false);
1919
let open: boolean = $state(false);
20-
let promotionError: string | null = $state(null);
2120
2221
async function promoteBuild(id: number) {
2322
if (promoting) return;
2423
25-
promotionError = null;
2624
promoting = true;
2725
try {
2826
const result = await client
@@ -51,27 +49,23 @@
5149
.toPromise();
5250
const errorMessage = getOperationErrorMessage(result.error, `promote build #${id}`);
5351
if (errorMessage) {
54-
promotionError = errorMessage;
52+
toast.error(errorMessage);
5553
return;
5654
}
5755
5856
if (!result.data?.promoteBuild) {
59-
promotionError = getUnexpectedOperationResultMessage(`promote build #${id}`);
57+
toast.error(getUnexpectedOperationResultMessage(`promote build #${id}`));
58+
return;
6059
}
60+
toast.success(`Build #${id} promoted to recommended.`);
6161
} catch (error) {
62-
promotionError = getUnexpectedOperationResultMessage(`promote build #${id}`, error);
62+
toast.error(getUnexpectedOperationResultMessage(`promote build #${id}`, error));
6363
} finally {
6464
promoting = false;
6565
}
6666
}
6767
</script>
6868

69-
{#if promotionError}
70-
<Alert.Root variant="destructive">
71-
<Alert.Description>{promotionError}</Alert.Description>
72-
</Alert.Root>
73-
{/if}
74-
7569
<AlertDialog.Root bind:open>
7670
<AlertDialog.Trigger>
7771
{#snippet child({ props })}

src/lib/components/custom/CopyToClipboard.svelte

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script lang="ts">
2+
import { onDestroy } from "svelte";
23
import Button, { type ButtonProps } from "$lib/components/ui/button/button.svelte";
34
import { mergeProps } from "bits-ui";
5+
import { toast } from "svelte-sonner";
46
57
interface Props extends ButtonProps {
68
text: string;
@@ -10,20 +12,30 @@
1012
1113
let { text, copyLabel = "Copy to clipboard", copiedLabel = "Copied to clipboard", ...restProps }: Props = $props();
1214
13-
let showCopied = $state(false);
14-
let stopShowCopied: ReturnType<typeof setTimeout> | undefined;
15+
let copyStatus: "idle" | "copied" | "failed" = $state("idle");
16+
let resetCopyStatus: ReturnType<typeof setTimeout> | undefined;
17+
18+
onDestroy(() => {
19+
if (resetCopyStatus) clearTimeout(resetCopyStatus);
20+
});
21+
22+
function setCopyStatus(status: typeof copyStatus) {
23+
copyStatus = status;
24+
if (resetCopyStatus) clearTimeout(resetCopyStatus);
25+
resetCopyStatus = setTimeout(() => {
26+
copyStatus = "idle";
27+
resetCopyStatus = undefined;
28+
}, 2000);
29+
}
1530
1631
async function copy() {
1732
try {
1833
await navigator.clipboard.writeText(text);
19-
showCopied = true;
20-
if (stopShowCopied) clearTimeout(stopShowCopied);
21-
stopShowCopied = setTimeout(() => {
22-
showCopied = false;
23-
stopShowCopied = undefined;
24-
}, 2000);
34+
setCopyStatus("copied");
2535
} catch (error) {
2636
console.error("Failed to copy:", error);
37+
setCopyStatus("failed");
38+
toast.error("Unable to copy to clipboard. Please try again.");
2739
}
2840
}
2941
@@ -38,9 +50,16 @@
3850
);
3951
</script>
4052

41-
<Button {...mergedProps} onclick={copy} aria-label={showCopied ? copiedLabel : copyLabel} title={showCopied ? copiedLabel : copyLabel}>
42-
{#if showCopied}
53+
<Button
54+
{...mergedProps}
55+
onclick={copy}
56+
aria-label={copyStatus === "copied" ? copiedLabel : copyStatus === "failed" ? "Unable to copy to clipboard" : copyLabel}
57+
title={copyStatus === "copied" ? copiedLabel : copyStatus === "failed" ? "Unable to copy to clipboard" : copyLabel}
58+
>
59+
{#if copyStatus === "copied"}
4360
<span class="iconify lucide--check"></span>
61+
{:else if copyStatus === "failed"}
62+
<span class="iconify lucide--x"></span>
4463
{:else}
4564
<span class="iconify lucide--copy"></span>
4665
{/if}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Toaster } from "./sonner.svelte";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<script lang="ts">
2+
import { mode } from "mode-watcher";
3+
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
4+
import Loader2Icon from '@lucide/svelte/icons/loader-2';
5+
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
6+
import OctagonXIcon from '@lucide/svelte/icons/octagon-x';
7+
import InfoIcon from '@lucide/svelte/icons/info';
8+
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
9+
10+
let { ...restProps }: SonnerProps = $props();
11+
</script>
12+
13+
<Sonner
14+
theme={mode.current}
15+
class="toaster group"
16+
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
17+
{...restProps}
18+
>
19+
{#snippet loadingIcon()}
20+
<Loader2Icon class="size-4 animate-spin" />
21+
{/snippet}
22+
{#snippet successIcon()}
23+
<CircleCheckIcon class="size-4" />
24+
{/snippet}
25+
{#snippet errorIcon()}
26+
<OctagonXIcon class="size-4" />
27+
{/snippet}
28+
{#snippet infoIcon()}
29+
<InfoIcon class="size-4" />
30+
{/snippet}
31+
{#snippet warningIcon()}
32+
<TriangleAlertIcon class="size-4" />
33+
{/snippet}
34+
</Sonner>

src/routes/+layout.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { type LayoutProps } from "./$types";
1616
import Footer from "./Footer.svelte";
1717
import { createAppHydrationScope } from "$lib/hydration-scope";
18+
import { Toaster } from "$lib/components/ui/sonner";
1819
1920
let { children, data }: LayoutProps = $props();
2021
@@ -78,6 +79,7 @@
7879
</svelte:head>
7980

8081
<ModeWatcher />
82+
<Toaster />
8183
<div class="mx-auto flex min-h-svh max-w-5xl flex-col">
8284
<div class="p-6">
8385
{@render children?.()}

src/routes/projects/[project]/version/[version]/VersionMetadata.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import { getOperationErrorMessage, getUnexpectedOperationResultMessage } from "$lib/operation-error";
1919
import { page } from "$app/state";
2020
import { AUTH_CTX } from "$lib/auth.svelte";
21+
import { toast } from "svelte-sonner";
2122
2223
const auth = AUTH_CTX.get();
2324
@@ -191,6 +192,7 @@
191192
192193
editMode = false;
193194
resetEditState();
195+
toast.success("Version metadata saved.");
194196
} catch (error) {
195197
saveError = getUnexpectedOperationResultMessage("save the version metadata", error);
196198
} finally {

src/routes/webhooks/+page.svelte

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { getOperationErrorMessage, getUnexpectedOperationResultMessage } from "$lib/operation-error";
2020
import { webhooksHeaderSegment } from "$lib/components/custom/header/index.svelte";
2121
import { formatDateTime } from "$lib/utils/date";
22+
import { toast } from "svelte-sonner";
2223
2324
const webhooksDocument = graphql(`
2425
query Webhooks {
@@ -71,7 +72,7 @@
7172
.filter((webhook): webhook is Webhook => webhook !== null)
7273
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
7374
);
74-
let operationError: string | null = $state(null);
75+
let createWebhookError: string | null = $state(null);
7576
let newWebhookUrl = $state("");
7677
let newWebhookUrlError: string | null = $state(null);
7778
let creating = $state(false);
@@ -88,7 +89,7 @@
8889
e.preventDefault();
8990
if (creating) return;
9091
91-
operationError = null;
92+
createWebhookError = null;
9293
newWebhookUrlError = null;
9394
const url = newWebhookUrl.trim();
9495
if (!url) {
@@ -101,13 +102,13 @@
101102
const result = await client.mutation(createWebhookMutation, { input: { url } }).toPromise();
102103
const errorMessage = getOperationErrorMessage(result.error, "create the webhook");
103104
if (errorMessage) {
104-
operationError = errorMessage;
105+
createWebhookError = errorMessage;
105106
return;
106107
}
107108
108109
const createdWebhook = result.data?.createWebhook;
109110
if (!createdWebhook) {
110-
operationError = getUnexpectedOperationResultMessage("create the webhook");
111+
createWebhookError = getUnexpectedOperationResultMessage("create the webhook");
111112
return;
112113
}
113114
@@ -122,14 +123,13 @@
122123
newWebhookUrl = "";
123124
refreshWebhooks();
124125
} catch (error) {
125-
operationError = getUnexpectedOperationResultMessage("create the webhook", error);
126+
createWebhookError = getUnexpectedOperationResultMessage("create the webhook", error);
126127
} finally {
127128
creating = false;
128129
}
129130
}
130131
131132
function requestWebhookDeletion(webhook: Webhook) {
132-
operationError = null;
133133
webhookPendingDeletion = webhook;
134134
deleteDialogOpen = true;
135135
}
@@ -138,25 +138,25 @@
138138
if (!webhookPendingDeletion || deleting) return;
139139
140140
deleting = true;
141-
operationError = null;
142141
try {
143142
const result = await client.mutation(deleteWebhookMutation, { input: { id: webhookPendingDeletion.id } }).toPromise();
144143
const errorMessage = getOperationErrorMessage(result.error, "delete the webhook");
145144
if (errorMessage) {
146-
operationError = errorMessage;
145+
toast.error(errorMessage);
147146
return;
148147
}
149148
150149
if (!result.data?.deleteWebhook?.ok) {
151-
operationError = getUnexpectedOperationResultMessage("delete the webhook");
150+
toast.error(getUnexpectedOperationResultMessage("delete the webhook"));
152151
return;
153152
}
154153
155154
deleteDialogOpen = false;
156155
webhookPendingDeletion = null;
157156
refreshWebhooks();
157+
toast.success("Webhook deleted.");
158158
} catch (error) {
159-
operationError = getUnexpectedOperationResultMessage("delete the webhook", error);
159+
toast.error(getUnexpectedOperationResultMessage("delete the webhook", error));
160160
} finally {
161161
deleting = false;
162162
}
@@ -172,11 +172,11 @@
172172

173173
<form onsubmit={createWebhook} class="max-w-2xl space-y-3">
174174
<h2 class="text-xl font-semibold">Add webhook</h2>
175-
{#if operationError}
175+
{#if createWebhookError}
176176
<Alert.Root variant="destructive">
177177
<CircleAlertIcon />
178178
<Alert.Title>Webhook action failed</Alert.Title>
179-
<Alert.Description>{operationError}</Alert.Description>
179+
<Alert.Description>{createWebhookError}</Alert.Description>
180180
</Alert.Root>
181181
{/if}
182182
<Field.FieldGroup>

0 commit comments

Comments
 (0)