Skip to content

Commit a6c673f

Browse files
committed
feat: Add clipboard utility
1 parent 783357d commit a6c673f

10 files changed

Lines changed: 93 additions & 55 deletions

File tree

src/lib/components/CopyInput.svelte

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script lang="ts">
22
import { CircleCheckBig, Copy } from '@lucide/svelte';
33
import { cn } from '$lib/utils';
4+
import { useClipboard } from '$lib/utils/clipboard.svelte';
45
import type { Snippet } from 'svelte';
5-
import { toast } from 'svelte-sonner';
66
import { scale } from 'svelte/transition';
77
88
interface Props {
@@ -14,14 +14,7 @@
1414
1515
let { value, class: className, icon, displayValue }: Props = $props();
1616
17-
let copied = $state(false);
18-
19-
function copyToken() {
20-
if (value == null) return;
21-
navigator.clipboard.writeText(value);
22-
copied = true;
23-
toast.success('Copied to clipboard');
24-
}
17+
const clip = useClipboard();
2518
</script>
2619

2720
<form
@@ -42,12 +35,12 @@
4235
/>
4336
<span>
4437
<button
45-
onclick={copyToken}
38+
onclick={() => clip.copy(value)}
4639
class="transition-in-place"
47-
title={copied ? 'Copied' : 'Copy to clipboard'}
40+
title={clip.copied ? 'Copied' : 'Copy to clipboard'}
4841
type="button"
4942
>
50-
{#if copied}
43+
{#if clip.copied}
5144
<span transition:scale>
5245
<CircleCheckBig size="20" />
5346
</span>

src/lib/utils/clipboard.svelte.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { onDestroy } from 'svelte';
2+
import { toast } from 'svelte-sonner';
3+
4+
const DEFAULT_SUCCESS_MESSAGE = 'Copied to clipboard';
5+
const DEFAULT_ERROR_MESSAGE = 'Failed to copy to clipboard';
6+
7+
/**
8+
* Writes text to the clipboard and toasts the outcome. Fire-and-forget — use
9+
* when no reactive feedback is needed. For a reactive `copied` flag, use the
10+
* `Clipboard` class.
11+
*/
12+
export async function copyToClipboard(
13+
text: string,
14+
successMessage: string = DEFAULT_SUCCESS_MESSAGE
15+
): Promise<boolean> {
16+
try {
17+
await navigator.clipboard.writeText(text);
18+
toast.success(successMessage);
19+
return true;
20+
} catch {
21+
// Insecure context, permission denied, tab not focused, etc.
22+
toast.error(DEFAULT_ERROR_MESSAGE);
23+
return false;
24+
}
25+
}
26+
27+
export interface ClipboardOptions {
28+
/** How long `copied` stays true after a successful copy, in ms. Defaults to 2000. */
29+
resetDelay?: number;
30+
}
31+
32+
/**
33+
* Clipboard wrapper with a reactive `copied` flag that auto-resets. Call
34+
* `dispose()` when done (or use `useClipboard` to do that automatically).
35+
*/
36+
export class Clipboard {
37+
readonly #resetDelay: number;
38+
#timeout?: ReturnType<typeof setTimeout>;
39+
#copied = $state(false);
40+
41+
constructor(options: ClipboardOptions = {}) {
42+
this.#resetDelay = options.resetDelay ?? 2000;
43+
}
44+
45+
get copied() {
46+
return this.#copied;
47+
}
48+
49+
async copy(text: string, successMessage?: string): Promise<boolean> {
50+
const ok = await copyToClipboard(text, successMessage);
51+
if (!ok) return false;
52+
53+
this.#copied = true;
54+
clearTimeout(this.#timeout);
55+
this.#timeout = setTimeout(() => (this.#copied = false), this.#resetDelay);
56+
return true;
57+
}
58+
59+
/** Clears any pending reset timeout. */
60+
dispose() {
61+
clearTimeout(this.#timeout);
62+
this.#timeout = undefined;
63+
}
64+
}
65+
66+
/** Component-aware `Clipboard` that clears pending reset timeouts on unmount. */
67+
export function useClipboard(options?: ClipboardOptions): Clipboard {
68+
const clip = new Clipboard(options);
69+
onDestroy(() => clip.dispose());
70+
return clip;
71+
}

src/routes/(app)/admin/online-hubs/data-table-actions.svelte

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { goto } from '$app/navigation';
44
import { Button } from '$lib/components/ui/button';
55
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6-
import { toast } from 'svelte-sonner';
6+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
77
import type { OnlineHub } from './columns';
88
import { resolve } from '$app/paths';
99
@@ -13,14 +13,8 @@
1313
1414
let { hub }: Props = $props();
1515
16-
function copyId() {
17-
navigator.clipboard.writeText(hub.id);
18-
toast.success('ID copied to clipboard');
19-
}
20-
function copyUserId() {
21-
navigator.clipboard.writeText(hub.owner.id);
22-
toast.success('User ID copied to clipboard');
23-
}
16+
const copyId = () => copyToClipboard(hub.id, 'ID copied to clipboard');
17+
const copyUserId = () => copyToClipboard(hub.owner.id, 'User ID copied to clipboard');
2418
</script>
2519

2620
<DropdownMenu.Root>

src/routes/(app)/admin/users/data-table-actions.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { type AdminUsersView, RoleType } from '$lib/api/internal/v1';
44
import { Button } from '$lib/components/ui/button';
55
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6-
import { toast } from 'svelte-sonner';
6+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
77
import UserDeleteDialog from './dialog-user-delete.svelte';
88
import UserEditDialog from './dialog-user-edit.svelte';
99
@@ -19,10 +19,7 @@
1919
[RoleType.Admin, RoleType.System].some((role) => user.roles.includes(role))
2020
);
2121
22-
function copyId() {
23-
navigator.clipboard.writeText(user.id);
24-
toast.success('ID copied to clipboard');
25-
}
22+
const copyId = () => copyToClipboard(user.id, 'ID copied to clipboard');
2623
</script>
2724

2825
<UserEditDialog bind:open={editDialogOpen} {user} />

src/routes/(app)/admin/webhooks/data-table-actions.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { type WebhookDto } from '$lib/api/internal/v1';
44
import { Button } from '$lib/components/ui/button';
55
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6-
import { toast } from 'svelte-sonner';
6+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
77
import WebhookDeleteDialog from './dialog-webhook-delete.svelte';
88
99
interface Props {
@@ -14,10 +14,7 @@
1414
1515
let deleteDialogOpen = $state<boolean>(false);
1616
17-
function copyId() {
18-
navigator.clipboard.writeText(webhook.id);
19-
toast.success('ID copied to clipboard');
20-
}
17+
const copyId = () => copyToClipboard(webhook.id, 'ID copied to clipboard');
2118
</script>
2219

2320
<WebhookDeleteDialog bind:open={deleteDialogOpen} {webhook} />

src/routes/(app)/hubs/data-table-actions.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { serializeEmergencyStopMessage } from '$lib/signalr/serializers/EmergencyStop';
1717
import { serializeRebootMessage } from '$lib/signalr/serializers/Reboot';
1818
import { refreshOwnHubs } from '$lib/state/hubs-state.svelte';
19-
import { toast } from 'svelte-sonner';
19+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
2020
import type { Hub } from './columns';
2121
import { resolve } from '$app/paths';
2222
@@ -26,10 +26,7 @@
2626
2727
let { hub }: Props = $props();
2828
29-
function copyId() {
30-
navigator.clipboard.writeText(hub.id);
31-
toast.success('ID copied to clipboard');
32-
}
29+
const copyId = () => copyToClipboard(hub.id, 'ID copied to clipboard');
3330
3431
async function editHub(name: string, close: () => void) {
3532
try {

src/routes/(app)/settings/api-tokens/data-table-actions.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { TokenResponse } from '$lib/api/internal/v1';
44
import { Button } from '$lib/components/ui/button';
55
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6-
import { toast } from 'svelte-sonner';
6+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
77
import TokenDeleteDialog from './dialog-token-delete.svelte';
88
import TokenEditDialog from './dialog-token-edit.svelte';
99
@@ -18,10 +18,7 @@
1818
let editDialogOpen = $state<boolean>(false);
1919
let deleteDialogOpen = $state<boolean>(false);
2020
21-
function copyId() {
22-
navigator.clipboard.writeText(token.id);
23-
toast.success('ID copied to clipboard');
24-
}
21+
const copyId = () => copyToClipboard(token.id, 'ID copied to clipboard');
2522
2623
function openDeleteDialog() {
2724
deleteDialogOpen = true;

src/routes/(app)/settings/sessions/data-table-actions.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { LoginSessionResponse } from '$lib/api/internal/v1';
44
import { Button } from '$lib/components/ui/button';
55
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6-
import { toast } from 'svelte-sonner';
6+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
77
import SessionRevokeDialog from './dialog-session-revoke.svelte';
88
99
interface Props {
@@ -15,10 +15,7 @@
1515
1616
let revokeDialogOpen = $state<boolean>(false);
1717
18-
function copyId() {
19-
navigator.clipboard.writeText(session.id);
20-
toast.success('ID copied to clipboard');
21-
}
18+
const copyId = () => copyToClipboard(session.id, 'ID copied to clipboard');
2219
</script>
2320

2421
<SessionRevokeDialog bind:open={revokeDialogOpen} {session} {onRevoked} />

src/routes/(app)/shares/public/data-table-actions.svelte

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import type { OwnPublicShareResponse } from '$lib/api/internal/v1';
66
import { Button } from '$lib/components/ui/button';
77
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
8-
import { toast } from 'svelte-sonner';
8+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
99
import SharelinkDeleteDialog from './dialog-publicshare-delete.svelte';
1010
1111
interface Props {
@@ -17,10 +17,7 @@
1717
1818
let deleteDialogOpen = $state<boolean>(false);
1919
20-
function copyId() {
21-
navigator.clipboard.writeText(publicShare.id);
22-
toast.success('ID copied to clipboard');
23-
}
20+
const copyId = () => copyToClipboard(publicShare.id, 'ID copied to clipboard');
2421
</script>
2522

2623
<SharelinkDeleteDialog bind:open={deleteDialogOpen} {publicShare} onDeleted={onChange} />

src/routes/(app)/shares/user/invites/outgoing-invite-item.svelte

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte';
1313
import { refreshOutgoingInvites } from '$lib/state/user-shares-state.svelte';
1414
import { cn } from '$lib/utils';
15+
import { copyToClipboard } from '$lib/utils/clipboard.svelte';
1516
import { toast } from 'svelte-sonner';
1617
1718
interface Props {
@@ -20,10 +21,7 @@
2021
2122
let { shareInvite = $bindable() }: Props = $props();
2223
23-
function copyCode() {
24-
navigator.clipboard.writeText(shareInvite.id);
25-
toast.success('Code copied to clipboard');
26-
}
24+
const copyCode = () => copyToClipboard(shareInvite.id, 'Code copied to clipboard');
2725
2826
async function removeInviteCall(invite: ShareInviteBaseDetails) {
2927
try {

0 commit comments

Comments
 (0)