andrewelawrence commited on
Commit
4bc8716
·
1 Parent(s): 3dca044

Add personas gallery page with search, edit modal, and activation features

Browse files
Files changed (1) hide show
  1. src/routes/personas/+page.svelte +270 -0
src/routes/personas/+page.svelte ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
3
+ import { useSettingsStore } from "$lib/stores/settings";
4
+ import type { Persona } from "$lib/types/Persona";
5
+ import { page } from "$app/state";
6
+ import Modal from "$lib/components/Modal.svelte";
7
+ import CarbonEdit from "~icons/carbon/edit";
8
+
9
+ const publicConfig = usePublicConfig();
10
+ const settings = useSettingsStore();
11
+
12
+ let clickTimeout: number | null = null;
13
+ let showCloseConfirm = $state(false);
14
+
15
+ // Local filter state for persona search (hyphen/space insensitive)
16
+ let personaFilter = $state("");
17
+ const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ");
18
+ let queryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
19
+ let filtered = $derived(
20
+ $settings.personas.filter((p: Persona) => {
21
+ const haystack = normalize(`${p.name} ${p.occupation ?? ""} ${p.stance ?? ""}`);
22
+ return queryTokens.every((q) => haystack.includes(q));
23
+ })
24
+ );
25
+
26
+ function activatePersona(personaId: string) {
27
+ if (personaId !== $settings.activePersona) {
28
+ settings.instantSet({ activePersona: personaId });
29
+ }
30
+ }
31
+
32
+ function handleCardClick(personaId: string) {
33
+ if (clickTimeout) return; // waiting for dblclick
34
+ clickTimeout = window.setTimeout(() => {
35
+ clickTimeout = null;
36
+ openEdit(personaId);
37
+ }, 220);
38
+ }
39
+
40
+ function handleCardDblClick(personaId: string) {
41
+ if (clickTimeout) {
42
+ clearTimeout(clickTimeout);
43
+ clickTimeout = null;
44
+ }
45
+ activatePersona(personaId);
46
+ }
47
+
48
+ // Edit modal state
49
+ let editingPersonaId = $state<string | null>(null);
50
+ let editingPersona = $derived(
51
+ $settings.personas.find((p) => p.id === editingPersonaId) ?? null
52
+ );
53
+ let editableName = $state("");
54
+ let editableOccupation = $state("");
55
+ let editableStance = $state("");
56
+ let editablePrompt = $state("");
57
+
58
+ $effect(() => {
59
+ if (editingPersona) {
60
+ editableName = editingPersona.name;
61
+ editableOccupation = editingPersona.occupation;
62
+ editableStance = editingPersona.stance;
63
+ editablePrompt = editingPersona.prompt;
64
+ }
65
+ });
66
+
67
+ function openEdit(personaId: string) {
68
+ editingPersonaId = personaId;
69
+ }
70
+
71
+ function closeEdit() {
72
+ editingPersonaId = null;
73
+ }
74
+
75
+ function saveEdit() {
76
+ if (!editingPersona) return;
77
+ $settings.personas = $settings.personas.map((p) =>
78
+ p.id === editingPersona.id
79
+ ? {
80
+ ...p,
81
+ name: editableName,
82
+ occupation: editableOccupation,
83
+ stance: editableStance,
84
+ prompt: editablePrompt,
85
+ updatedAt: new Date(),
86
+ }
87
+ : p
88
+ );
89
+ closeEdit();
90
+ }
91
+
92
+ function activateEditingPersona() {
93
+ if (!editingPersona) return;
94
+ const id = editingPersona.id;
95
+ saveEdit();
96
+ activatePersona(id);
97
+ }
98
+
99
+ function deleteEditingPersona() {
100
+ if (!editingPersona) return;
101
+ if ($settings.personas.length === 1) return alert("Cannot delete the last persona.");
102
+ if (editingPersona.id === $settings.activePersona)
103
+ return alert("Cannot delete the active persona. Activate another first.");
104
+ if (confirm(`Delete "${editingPersona.name}"?`)) {
105
+ $settings.personas = $settings.personas.filter((p) => p.id !== editingPersona!.id);
106
+ closeEdit();
107
+ }
108
+ }
109
+ </script>
110
+
111
+ <svelte:head>
112
+ <title>{publicConfig.PUBLIC_APP_NAME} - Personas</title>
113
+ <meta property="og:title" content="Personas" />
114
+ <meta property="og:url" content={page.url.href} />
115
+ </svelte:head>
116
+
117
+ <div class="scrollbar-custom h-full overflow-y-auto py-12 max-sm:pt-8 md:py-24">
118
+ <div class="pt-42 mx-auto flex flex-col px-5 xl:w-[60rem] 2xl:w-[64rem]">
119
+ <div class="flex items-center">
120
+ <h1 class="text-2xl font-bold">Personas</h1>
121
+ </div>
122
+ <h2 class="text-gray-500">All personas available on {publicConfig.PUBLIC_APP_NAME}</h2>
123
+
124
+ <!-- Filter input -->
125
+ <input
126
+ type="search"
127
+ bind:value={personaFilter}
128
+ placeholder="Search by name, occupation, or stance"
129
+ aria-label="Search personas"
130
+ class="mt-4 w-full rounded-3xl border border-gray-300 bg-white px-5 py-2 text-[15px]
131
+ placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300
132
+ dark:border-gray-700 dark:bg-gray-900 dark:focus:ring-gray-700"
133
+ />
134
+
135
+ <div class="mt-6 grid grid-cols-1 gap-3 sm:gap-5 xl:grid-cols-2">
136
+ {#each filtered as persona (persona.id)}
137
+ <div
138
+ role="button"
139
+ tabindex="0"
140
+ onclick={() => handleCardClick(persona.id)}
141
+ ondblclick={() => handleCardDblClick(persona.id)}
142
+ onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleCardClick(persona.id); } }}
143
+ aria-label={`Open persona ${persona.name}`}
144
+ class="group relative flex min-h-[112px] flex-col gap-2 overflow-hidden rounded-xl border bg-gray-50/50 px-6 py-5 text-left shadow hover:bg-gray-50 hover:shadow-inner dark:border-gray-800/70 dark:bg-gray-950/20 dark:hover:bg-gray-950/40"
145
+ class:active-model={persona.id === $settings.activePersona}
146
+ >
147
+ <div class="flex items-center justify-between gap-1">
148
+ <span class="flex items-center gap-2 font-semibold">{persona.name}</span>
149
+ {#if persona.id === $settings.activePersona}
150
+ <span class="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Active</span>
151
+ {/if}
152
+ </div>
153
+ {#if persona.occupation || persona.stance}
154
+ <div class="text-sm text-gray-600 dark:text-gray-300">
155
+ {#if persona.occupation}<span class="font-medium">{persona.occupation}</span>{/if}
156
+ {#if persona.occupation && persona.stance}<span class="mx-1 text-gray-400">•</span>{/if}
157
+ {#if persona.stance}<span class="italic">{persona.stance}</span>{/if}
158
+ </div>
159
+ {/if}
160
+ </div>
161
+ {/each}
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ {#if editingPersona}
167
+ <Modal onclose={() => {
168
+ const dirty = editingPersona && (
169
+ editableName !== editingPersona.name ||
170
+ editableOccupation !== editingPersona.occupation ||
171
+ editableStance !== editingPersona.stance ||
172
+ editablePrompt !== editingPersona.prompt
173
+ );
174
+ if (!dirty) return closeEdit();
175
+ showCloseConfirm = true;
176
+ }} width="w-full !max-w-2xl">
177
+ <div class="flex h-full max-h-[80vh] w-full flex-col gap-5 p-6">
178
+ <div class="text-xl font-semibold text-gray-800 dark:text-gray-200">Edit Persona</div>
179
+ <div class="flex flex-col gap-2">
180
+ <label for="edit-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
181
+ <div class="relative">
182
+ <input
183
+ id="edit-name"
184
+ bind:value={editableName}
185
+ class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
186
+ />
187
+ <CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
188
+ </div>
189
+ </div>
190
+ <div class="flex flex-col gap-2">
191
+ <label for="edit-role" class="text-sm font-medium text-gray-700 dark:text-gray-300">Role</label>
192
+ <div class="relative">
193
+ <input
194
+ id="edit-role"
195
+ bind:value={editableOccupation}
196
+ class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
197
+ />
198
+ <CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
199
+ </div>
200
+ </div>
201
+ <div class="flex flex-col gap-2">
202
+ <label for="edit-stance" class="text-sm font-medium text-gray-700 dark:text-gray-300">Stance</label>
203
+ <div class="relative">
204
+ <input
205
+ id="edit-stance"
206
+ bind:value={editableStance}
207
+ class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
208
+ />
209
+ <CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
210
+ </div>
211
+ </div>
212
+ <div class="flex min-h-0 flex-1 flex-col gap-2">
213
+ <label for="edit-prompt" class="text-sm font-medium text-gray-700 dark:text-gray-300">System Prompt</label>
214
+ <div class="relative flex flex-1">
215
+ <textarea
216
+ id="edit-prompt"
217
+ bind:value={editablePrompt}
218
+ class="peer scrollbar-custom h-full min-h-[200px] w-full flex-1 resize-none overflow-y-auto rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
219
+ ></textarea>
220
+ <CarbonEdit class="pointer-events-none absolute right-3 top-3 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
221
+ </div>
222
+ </div>
223
+ <div class="flex flex-wrap gap-2 pt-2">
224
+ <button
225
+ class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
226
+ onclick={activateEditingPersona}
227
+ disabled={editingPersona.id === $settings.activePersona}
228
+ >
229
+ {editingPersona.id === $settings.activePersona ? "Active" : "Activate"}
230
+ </button>
231
+ <button
232
+ class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
233
+ onclick={saveEdit}
234
+ >
235
+ Save
236
+ </button>
237
+ <button
238
+ class="ml-auto flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:border-red-700 dark:bg-gray-800 dark:hover:bg-red-900/20"
239
+ onclick={deleteEditingPersona}
240
+ >
241
+ Delete
242
+ </button>
243
+ </div>
244
+ </div>
245
+ </Modal>
246
+ {/if}
247
+
248
+ {#if editingPersona && showCloseConfirm}
249
+ <Modal onclose={() => (showCloseConfirm = false)} width="w-full !max-w-sm">
250
+ <div class="flex w-full flex-col gap-4 p-4">
251
+ <div class="text-base font-semibold text-gray-800 dark:text-gray-200">Unsaved changes</div>
252
+ <p class="text-sm text-gray-600 dark:text-gray-300">Save your changes before closing?</p>
253
+ <div class="mt-2 flex gap-2">
254
+ <button class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300" onclick={() => { showCloseConfirm = false; saveEdit(); }}>
255
+ Save
256
+ </button>
257
+ <button class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300" onclick={() => { showCloseConfirm = false; closeEdit(); }}>
258
+ Discard
259
+ </button>
260
+ <button class="ml-auto rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300" onclick={() => { showCloseConfirm = false; }}>
261
+ Cancel
262
+ </button>
263
+ </div>
264
+ </div>
265
+ </Modal>
266
+ {/if}
267
+
268
+ <!-- merged into top script -->
269
+
270
+