Thomas G. Lopes commited on
Commit
91d01ad
·
unverified ·
2 Parent(s): 4766af6 95f962f

Share projects (#71)

Browse files
package.json CHANGED
@@ -37,7 +37,7 @@
37
  "globals": "^16.0.0",
38
  "highlight.js": "^11.10.0",
39
  "jiti": "^2.4.2",
40
- "melt": "^0.20.2",
41
  "postcss": "^8.4.38",
42
  "prettier": "^3.1.1",
43
  "prettier-plugin-svelte": "^3.2.6",
 
37
  "globals": "^16.0.0",
38
  "highlight.js": "^11.10.0",
39
  "jiti": "^2.4.2",
40
+ "melt": "^0.21.0",
41
  "postcss": "^8.4.38",
42
  "prettier": "^3.1.1",
43
  "prettier-plugin-svelte": "^3.2.6",
pnpm-lock.yaml CHANGED
@@ -85,8 +85,8 @@ importers:
85
  specifier: ^2.4.2
86
  version: 2.4.2
87
  melt:
88
- specifier: ^0.20.2
89
- version: 0.20.2(@floating-ui/[email protected])([email protected])
90
  postcss:
91
  specifier: ^8.4.38
92
  version: 8.5.3
@@ -1548,8 +1548,8 @@ packages:
1548
1549
  resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
1550
 
1551
- melt@0.20.2:
1552
- resolution: {integrity: sha512-vINBKFVqFN8LVU6K7AbEwlGTOzSi5BQulxhKkIroxPWnCA3LVsQEIDZabtf9TtnzFa/th0Rm8J0N+hW1v7VJDw==}
1553
  peerDependencies:
1554
  '@floating-ui/dom': ^1.6.0
1555
  svelte: ^5.0.0
@@ -3474,7 +3474,7 @@ snapshots:
3474
  dependencies:
3475
  '@jridgewell/sourcemap-codec': 1.5.0
3476
 
3477
- melt@0.20.2(@floating-ui/[email protected])([email protected]):
3478
  dependencies:
3479
  '@floating-ui/dom': 1.6.13
3480
  jest-axe: 9.0.0
 
85
  specifier: ^2.4.2
86
  version: 2.4.2
87
  melt:
88
+ specifier: ^0.21.0
89
+ version: 0.21.0(@floating-ui/[email protected])([email protected])
90
  postcss:
91
  specifier: ^8.4.38
92
  version: 8.5.3
 
1548
1549
  resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
1550
 
1551
+ melt@0.21.0:
1552
+ resolution: {integrity: sha512-eD0gGaer3CDB8vklec8KWx9X8Gi5BqufZo4eEvRc3GNPpBvAi3i0ZCuziG+/C1jkL+a+Mi6tSKqiBMfyOdJskg==}
1553
  peerDependencies:
1554
  '@floating-ui/dom': ^1.6.0
1555
  svelte: ^5.0.0
 
3474
  dependencies:
3475
  '@jridgewell/sourcemap-codec': 1.5.0
3476
 
3477
+ melt@0.21.0(@floating-ui/[email protected])([email protected]):
3478
  dependencies:
3479
  '@floating-ui/dom': 1.6.13
3480
  jest-axe: 9.0.0
src/app.css CHANGED
@@ -68,3 +68,7 @@ html {
68
  body {
69
  overflow: hidden;
70
  }
 
 
 
 
 
68
  body {
69
  overflow: hidden;
70
  }
71
+
72
+ body.dark {
73
+ color-scheme: dark;
74
+ }
src/lib/components/inference-playground/playground.svelte CHANGED
@@ -9,12 +9,17 @@
9
  import { token } from "$lib/state/token.svelte.js";
10
  import { isMac } from "$lib/utils/platform.js";
11
  import { HfInference } from "@huggingface/inference";
 
12
  import IconExternal from "~icons/carbon/arrow-up-right";
13
- import IconSettings from "~icons/carbon/settings";
14
  import IconCode from "~icons/carbon/code";
15
  import IconCompare from "~icons/carbon/compare";
16
  import IconInfo from "~icons/carbon/information";
 
 
17
  import { default as IconDelete } from "~icons/carbon/trash-can";
 
 
 
18
  import { addToast } from "../toaster.svelte.js";
19
  import PlaygroundConversationHeader from "./conversation-header.svelte";
20
  import PlaygroundConversation from "./conversation.svelte";
@@ -23,9 +28,6 @@
23
  import ModelSelectorModal from "./model-selector-modal.svelte";
24
  import ModelSelector from "./model-selector.svelte";
25
  import ProjectSelect from "./project-select.svelte";
26
- import { showQuotaModal } from "../quota-modal.svelte";
27
- import Toaster from "../toaster.svelte";
28
- import typia from "typia";
29
 
30
  const startMessageUser: ConversationMessage = { role: "user", content: "" };
31
 
@@ -364,22 +366,35 @@
364
  </div>
365
 
366
  <GenerationConfig bind:conversation={session.project.conversations[0]!} />
367
- {#if token.value}
 
368
  <button
369
- onclick={token.reset}
370
- class="mt-auto flex items-center gap-1 self-end text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
371
- ><svg xmlns="http://www.w3.org/2000/svg" class="text-xs" width="1em" height="1em" viewBox="0 0 32 32"
372
- ><path
373
- fill="currentColor"
374
- d="M23.216 4H26V2h-7v6h2V5.096A11.96 11.96 0 0 1 28 16c0 6.617-5.383 12-12 12v2c7.72 0 14-6.28 14-14c0-5.009-2.632-9.512-6.784-12"
375
- /><path fill="currentColor" d="M16 20a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M15 9h2v9h-2z" /><path
376
- fill="currentColor"
377
- d="M16 4V2C8.28 2 2 8.28 2 16c0 4.977 2.607 9.494 6.784 12H6v2h7v-6h-2v2.903A11.97 11.97 0 0 1 4 16C4 9.383 9.383 4 16 4"
378
- /></svg
379
- >
380
- Reset token</button
381
  >
382
- {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  <div class="mt-auto hidden">
384
  <div class="mb-3 flex items-center justify-between gap-2">
385
  <label for="default-range" class="block text-sm font-medium text-gray-900 dark:text-white">API Quota</label>
 
9
  import { token } from "$lib/state/token.svelte.js";
10
  import { isMac } from "$lib/utils/platform.js";
11
  import { HfInference } from "@huggingface/inference";
12
+ import typia from "typia";
13
  import IconExternal from "~icons/carbon/arrow-up-right";
 
14
  import IconCode from "~icons/carbon/code";
15
  import IconCompare from "~icons/carbon/compare";
16
  import IconInfo from "~icons/carbon/information";
17
+ import IconSettings from "~icons/carbon/settings";
18
+ import IconShare from "~icons/carbon/share";
19
  import { default as IconDelete } from "~icons/carbon/trash-can";
20
+ import { showQuotaModal } from "../quota-modal.svelte";
21
+ import { showShareModal } from "../share-modal.svelte";
22
+ import Toaster from "../toaster.svelte";
23
  import { addToast } from "../toaster.svelte.js";
24
  import PlaygroundConversationHeader from "./conversation-header.svelte";
25
  import PlaygroundConversation from "./conversation.svelte";
 
28
  import ModelSelectorModal from "./model-selector-modal.svelte";
29
  import ModelSelector from "./model-selector.svelte";
30
  import ProjectSelect from "./project-select.svelte";
 
 
 
31
 
32
  const startMessageUser: ConversationMessage = { role: "user", content: "" };
33
 
 
366
  </div>
367
 
368
  <GenerationConfig bind:conversation={session.project.conversations[0]!} />
369
+
370
+ <div class="mt-auto flex items-center justify-end gap-4">
371
  <button
372
+ onclick={() => showShareModal(session.project)}
373
+ class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
 
 
 
 
 
 
 
 
 
 
374
  >
375
+ <IconShare class="text-xs" />
376
+ Share
377
+ </button>
378
+ {#if token.value}
379
+ <button
380
+ onclick={token.reset}
381
+ class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
382
+ >
383
+ <svg xmlns="http://www.w3.org/2000/svg" class="text-xs" width="1em" height="1em" viewBox="0 0 32 32">
384
+ <path
385
+ fill="currentColor"
386
+ d="M23.216 4H26V2h-7v6h2V5.096A11.96 11.96 0 0 1 28 16c0 6.617-5.383 12-12 12v2c7.72 0 14-6.28 14-14c0-5.009-2.632-9.512-6.784-12"
387
+ />
388
+ <path fill="currentColor" d="M16 20a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M15 9h2v9h-2z" /><path
389
+ fill="currentColor"
390
+ d="M16 4V2C8.28 2 2 8.28 2 16c0 4.977 2.607 9.494 6.784 12H6v2h7v-6h-2v2.903A11.97 11.97 0 0 1 4 16C4 9.383 9.383 4 16 4"
391
+ />
392
+ </svg>
393
+ Reset token
394
+ </button>
395
+ {/if}
396
+ </div>
397
+
398
  <div class="mt-auto hidden">
399
  <div class="mb-3 flex items-center justify-between gap-2">
400
  <label for="default-range" class="block text-sm font-medium text-gray-900 dark:text-white">API Quota</label>
src/lib/components/inference-playground/project-select.svelte CHANGED
@@ -49,29 +49,31 @@
49
  </div>
50
  </button>
51
 
52
- {#if isDefault}
53
- <Tooltip>
54
- {#snippet trigger(tooltip)}
55
- <button class="btn size-[32px] p-0" {...tooltip.trigger} onclick={saveProject}>
56
- <IconSave />
57
- </button>
58
- {/snippet}
59
- Save to Project
60
- </Tooltip>
61
- {:else}
62
- <Tooltip>
63
- {#snippet trigger(tooltip)}
64
- <button
65
- class="btn size-[32px] p-0"
66
- {...tooltip.trigger}
67
- onclick={() => (session.$.activeProjectId = "default")}
68
- >
69
- <IconCross />
70
- </button>
71
- {/snippet}
72
- Close project
73
- </Tooltip>
74
- {/if}
 
 
75
  </div>
76
 
77
  <div {...select.content} class="rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
 
49
  </div>
50
  </button>
51
 
52
+ <div class="flex items-center gap-2">
53
+ {#if isDefault}
54
+ <Tooltip>
55
+ {#snippet trigger(tooltip)}
56
+ <button class="btn size-[32px] p-0" {...tooltip.trigger} onclick={saveProject}>
57
+ <IconSave />
58
+ </button>
59
+ {/snippet}
60
+ Save to Project
61
+ </Tooltip>
62
+ {:else}
63
+ <Tooltip>
64
+ {#snippet trigger(tooltip)}
65
+ <button
66
+ class="btn size-[32px] p-0"
67
+ {...tooltip.trigger}
68
+ onclick={() => (session.$.activeProjectId = "default")}
69
+ >
70
+ <IconCross />
71
+ </button>
72
+ {/snippet}
73
+ Close project
74
+ </Tooltip>
75
+ {/if}
76
+ </div>
77
  </div>
78
 
79
  <div {...select.content} class="rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
src/lib/components/local-toasts.svelte ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { autoUpdate, computePosition } from "@floating-ui/dom";
3
+ import { Toaster } from "melt/builders";
4
+ import { type Snippet } from "svelte";
5
+ import { fly } from "svelte/transition";
6
+
7
+ interface Props {
8
+ children: Snippet<[{ addToast: typeof toaster.addToast; trigger: typeof trigger }]>;
9
+ closeDelay?: number;
10
+ }
11
+ const { children, closeDelay = 2000 }: Props = $props();
12
+
13
+ const id = $props.id();
14
+
15
+ const trigger = {
16
+ id,
17
+ } as const;
18
+
19
+ type ToastData = {
20
+ content: string;
21
+ variant: "info" | "danger";
22
+ };
23
+
24
+ const toaster = new Toaster<ToastData>({
25
+ hover: null,
26
+ closeDelay: () => closeDelay,
27
+ });
28
+
29
+ function float(node: HTMLElement) {
30
+ const triggerEl = document.getElementById(trigger.id);
31
+ if (!triggerEl) return;
32
+
33
+ const compute = () =>
34
+ computePosition(triggerEl, node, {
35
+ placement: "top",
36
+ strategy: "absolute",
37
+ }).then(({ x, y }) => {
38
+ Object.assign(node.style, {
39
+ left: `${x}px`,
40
+ top: `${y - 8}px`,
41
+ });
42
+ });
43
+
44
+ return {
45
+ destroy: autoUpdate(triggerEl, node, compute),
46
+ };
47
+ }
48
+
49
+ const classMap: Record<ToastData["variant"], string> = {
50
+ info: "border border-blue-400 bg-gradient-to-b from-blue-500 to-blue-600",
51
+
52
+ danger: "border border-red-400 bg-gradient-to-b from-red-500 to-red-600",
53
+ };
54
+ </script>
55
+
56
+ {@render children({ trigger, addToast: toaster.addToast })}
57
+
58
+ {#each toaster.toasts as toast (toast.id)}
59
+ <div
60
+ data-local-toast
61
+ data-variant={toast.data.variant}
62
+ class="rounded-full px-2 py-1 text-xs {classMap[toast.data.variant]}"
63
+ in:fly={{ y: 10 }}
64
+ out:fly={{ y: -4 }}
65
+ use:float
66
+ >
67
+ {toast.data.content}
68
+ </div>
69
+ {/each}
70
+
71
+ <style>
72
+ [data-local-toast] {
73
+ /* Float on top of the UI */
74
+ position: absolute;
75
+
76
+ /* Avoid layout interference */
77
+ width: max-content;
78
+ top: 0;
79
+ left: 0;
80
+ }
81
+ </style>
src/lib/components/share-modal.svelte ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts" module>
2
+ let project = $state<Project>();
3
+
4
+ export function showShareModal(p: Project) {
5
+ project = p;
6
+ }
7
+
8
+ function close() {
9
+ project = undefined;
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import { clickOutside } from "$lib/actions/click-outside.js";
15
+ import { session } from "$lib/state/session.svelte";
16
+ import type { Project } from "$lib/types.js";
17
+ import { copyToClipboard } from "$lib/utils/copy.js";
18
+ import { decodeString, encodeObject } from "$lib/utils/encode.js";
19
+ import { fade, scale } from "svelte/transition";
20
+ import typia from "typia";
21
+ import IconCross from "~icons/carbon/close";
22
+ import IconCopy from "~icons/carbon/copy";
23
+ import IconSave from "~icons/carbon/save";
24
+ import LocalToasts from "./local-toasts.svelte";
25
+ import { addToast as addToastGlobally } from "./toaster.svelte.js";
26
+
27
+ let dialog: HTMLDialogElement | undefined = $state();
28
+
29
+ const open = $derived(!!project);
30
+ const encoded = $derived(encodeObject(project));
31
+ let pasted = $state("");
32
+
33
+ $effect(() => {
34
+ if (open) {
35
+ dialog?.showModal();
36
+ } else {
37
+ setTimeout(() => {
38
+ dialog?.close();
39
+ pasted = "";
40
+ }, 250);
41
+ }
42
+ });
43
+
44
+ const isProject = typia.createIs<Project>();
45
+ </script>
46
+
47
+ <dialog class="backdrop:bg-transparent" bind:this={dialog} onclose={() => close()}>
48
+ {#if open}
49
+ <!-- Backdrop -->
50
+ <div
51
+ class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-black/50 backdrop-blur-sm"
52
+ transition:fade={{ duration: 150 }}
53
+ >
54
+ <!-- Content -->
55
+ <div
56
+ class="relative w-xl rounded-xl bg-white shadow-sm dark:bg-gray-900"
57
+ use:clickOutside={() => close()}
58
+ transition:scale={{ start: 0.975, duration: 250 }}
59
+ >
60
+ <div class="flex items-center justify-between rounded-t border-b p-4 md:px-5 md:py-4 dark:border-gray-800">
61
+ <h2 class="flex items-center gap-2.5 text-lg font-semibold text-gray-900 dark:text-white">Sharing</h2>
62
+ <button
63
+ type="button"
64
+ class="ms-auto inline-flex h-8 w-8 items-center justify-center rounded-lg bg-transparent text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white"
65
+ onclick={close}
66
+ >
67
+ <div class="text-xl">
68
+ <IconCross />
69
+ </div>
70
+ <span class="sr-only">Close modal</span>
71
+ </button>
72
+ </div>
73
+ <!-- Modal body -->
74
+ <div class="p-4 md:p-5">
75
+ <h3 class="text-lg font-semibold">Share your project</h3>
76
+ <p>Copy an unique string that shares your entire project until this point.</p>
77
+ <div class="mt-4 flex gap-2">
78
+ <input
79
+ class="grow cursor-not-allowed rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
80
+ type="text"
81
+ value={encoded}
82
+ disabled
83
+ />
84
+ <LocalToasts>
85
+ {#snippet children({ addToast, trigger })}
86
+ <button
87
+ {...trigger}
88
+ class="btn flex items-center gap-2"
89
+ onclick={() => {
90
+ copyToClipboard(encoded);
91
+ addToast({ data: { content: "Copied to clipboard", variant: "info" } });
92
+ }}
93
+ >
94
+ <IconCopy />
95
+ Copy
96
+ </button>
97
+ {/snippet}
98
+ </LocalToasts>
99
+ </div>
100
+
101
+ <div class="my-4 flex items-center gap-2">
102
+ <div class="h-px grow bg-neutral-500" aria-hidden="true"></div>
103
+ <span class="text-xs text-neutral-400">or</span>
104
+ <div class="h-px grow bg-neutral-500" aria-hidden="true"></div>
105
+ </div>
106
+
107
+ <h3 class="text-lg font-semibold">Save a copied project</h3>
108
+ <p>Paste a copied project string, and save it for your local usage.</p>
109
+ <LocalToasts>
110
+ {#snippet children({ addToast, trigger })}
111
+ <form
112
+ class="mt-4 flex gap-2"
113
+ onsubmit={e => {
114
+ e.preventDefault();
115
+ const decoded = decodeString(pasted);
116
+ if (!isProject(decoded)) {
117
+ addToast({ data: { content: "String isn't valid", variant: "danger" } });
118
+ return;
119
+ }
120
+ session.addProject({ ...decoded, name: `Saved - ${decoded.name}`, id: crypto.randomUUID() });
121
+ addToastGlobally({
122
+ variant: "success",
123
+ title: "Saved project",
124
+ description: "The project you pasted in was successfully saved.",
125
+ });
126
+ close();
127
+ }}
128
+ >
129
+ <input
130
+ class="grow rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
131
+ type="text"
132
+ bind:value={pasted}
133
+ />
134
+ <button {...trigger} class="btn flex items-center gap-2" type="submit">
135
+ <IconSave />
136
+ Save
137
+ </button>
138
+ </form>
139
+ {/snippet}
140
+ </LocalToasts>
141
+ </div>
142
+
143
+ <!-- Modal footer -->
144
+ <!--
145
+ <div class="flex rounded-b border-t border-gray-200 p-4 md:p-5 dark:border-gray-800">
146
+ <button
147
+ type="submit"
148
+ class="ml-auto rounded-lg bg-black px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-900 focus:ring-4 focus:ring-gray-300 focus:outline-hidden dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus:ring-gray-700"
149
+ >Submit</button
150
+ >
151
+ </div>
152
+ -->
153
+ </div>
154
+ </div>
155
+ {/if}
156
+ </dialog>
src/lib/state/session.svelte.ts CHANGED
@@ -150,6 +150,10 @@ class SessionState {
150
 
151
  defaultProject.conversations = [getDefaults().defaultConversation];
152
 
 
 
 
 
153
  this.$ = { ...this.$, projects: [...this.$.projects, project], activeProjectId: project.id };
154
  };
155
 
 
150
 
151
  defaultProject.conversations = [getDefaults().defaultConversation];
152
 
153
+ this.addProject(project);
154
+ };
155
+
156
+ addProject = (project: Project) => {
157
  this.$ = { ...this.$, projects: [...this.$.projects, project], activeProjectId: project.id };
158
  };
159
 
src/lib/utils/copy.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copies a string to the clipboard, with a fallback for older browsers.
3
+ *
4
+ * @param text The string to copy to the clipboard.
5
+ * @returns A promise that resolves when the text has been successfully copied,
6
+ * or rejects if the copy operation fails.
7
+ */
8
+ export async function copyToClipboard(text: string): Promise<void> {
9
+ if (navigator.clipboard) {
10
+ try {
11
+ await navigator.clipboard.writeText(text);
12
+ return; // Resolve immediately if successful
13
+ } catch {
14
+ // Fallback to the older method
15
+ }
16
+ }
17
+
18
+ // Fallback for browsers that don't support the Clipboard API
19
+ try {
20
+ const textArea = document.createElement("textarea");
21
+ textArea.value = text;
22
+
23
+ // Avoid scrolling to bottom of page in MS Edge.
24
+ textArea.style.top = "0";
25
+ textArea.style.left = "0";
26
+ textArea.style.position = "fixed";
27
+
28
+ document.body.appendChild(textArea);
29
+ textArea.focus();
30
+ textArea.select();
31
+
32
+ const successful = document.execCommand("copy");
33
+ document.body.removeChild(textArea);
34
+
35
+ if (!successful) {
36
+ throw new Error("Failed to copy text using fallback method.");
37
+ }
38
+ } catch (err) {
39
+ return Promise.reject(err);
40
+ }
41
+ }
src/lib/utils/encode.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function encodeObject(obj: unknown): string {
2
+ /**
3
+ * Encodes an object to a string using JSON serialization and Base64 encoding.
4
+ *
5
+ * Args:
6
+ * obj: The object to encode.
7
+ *
8
+ * Returns:
9
+ * A string representation of the object.
10
+ */
11
+ const jsonString: string = JSON.stringify(obj);
12
+ const encodedString: string = btoa(unescape(encodeURIComponent(jsonString))); // btoa expects only ASCII chars
13
+ return encodedString;
14
+ }
15
+
16
+ export function decodeString(encodedString: string): unknown {
17
+ /**
18
+ * Decodes a string to an object using Base64 decoding and JSON deserialization.
19
+ *
20
+ * Args:
21
+ * encodedString: The string to decode.
22
+ *
23
+ * Returns:
24
+ * The decoded object.
25
+ */
26
+ try {
27
+ const jsonString: string = decodeURIComponent(escape(atob(encodedString)));
28
+ const obj: unknown = JSON.parse(jsonString);
29
+ return obj;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
src/routes/+layout.svelte CHANGED
@@ -2,6 +2,7 @@
2
  import DebugMenu from "$lib/components/debug-menu.svelte";
3
  import Prompts from "$lib/components/prompts.svelte";
4
  import QuotaModal from "$lib/components/quota-modal.svelte";
 
5
  import "../app.css";
6
 
7
  interface Props {
@@ -12,6 +13,8 @@
12
  </script>
13
 
14
  {@render children?.()}
 
15
  <DebugMenu />
16
  <Prompts />
17
  <QuotaModal />
 
 
2
  import DebugMenu from "$lib/components/debug-menu.svelte";
3
  import Prompts from "$lib/components/prompts.svelte";
4
  import QuotaModal from "$lib/components/quota-modal.svelte";
5
+ import ShareModal from "$lib/components/share-modal.svelte";
6
  import "../app.css";
7
 
8
  interface Props {
 
13
  </script>
14
 
15
  {@render children?.()}
16
+
17
  <DebugMenu />
18
  <Prompts />
19
  <QuotaModal />
20
+ <ShareModal />