Spaces:
Runtime error
Runtime error
Commit ·
4bc8716
1
Parent(s): 3dca044
Add personas gallery page with search, edit modal, and activation features
Browse files- 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 |
+
|