mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
added tag filtering
This commit is contained in:
parent
94b9957b86
commit
b1d9839686
16 changed files with 977 additions and 53 deletions
10
src-tauri/migrations/022_vault_tags.sql
Normal file
10
src-tauri/migrations/022_vault_tags.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS vault_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'character' | 'lorebook' | 'scenario'
|
||||
color TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(name, type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_tags_type ON vault_tags(type);
|
||||
|
|
@ -135,6 +135,12 @@ pub fn run() {
|
|||
description: "translation",
|
||||
sql: include_str!("../migrations/021_translation.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 22,
|
||||
description: "vault_tags",
|
||||
sql: include_str!("../migrations/022_vault_tags.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
32
src/lib/components/tags/TagBadge.svelte
Normal file
32
src/lib/components/tags/TagBadge.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
color?: string;
|
||||
onRemove?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { name, color = 'bg-surface-600', onRemove, class: className = '' }: Props = $props();
|
||||
|
||||
// Map of color names to Tailwind classes if needed, or assume 'color' is a tailwind color suffix like 'red-500'
|
||||
// If the color prop is just 'red-500', we need to construct the full class
|
||||
const bgClass = $derived(color.startsWith('bg-') ? color : `bg-${color}/20 text-${color} border-${color}/30`);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium border {bgClass} {className}"
|
||||
>
|
||||
{name}
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
class="ml-0.5 rounded-sm hover:bg-black/20 p-0.5"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
<span class="sr-only">Remove {name}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
150
src/lib/components/tags/TagInput.svelte
Normal file
150
src/lib/components/tags/TagInput.svelte
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<script lang="ts">
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import type { VaultType } from '$lib/types';
|
||||
import { Check, Plus, X } from 'lucide-svelte';
|
||||
import TagBadge from './TagBadge.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
value: string[]; // List of tag names
|
||||
type: VaultType;
|
||||
placeholder?: string;
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
let { value, type, placeholder = 'Add tags...', onChange }: Props = $props();
|
||||
|
||||
let input = $state('');
|
||||
let isOpen = $state(false);
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
// Derived state
|
||||
const allTags = $derived(tagStore.getTagsForType(type));
|
||||
|
||||
const filteredTags = $derived.by(() => {
|
||||
const term = input.trim().toLowerCase();
|
||||
if (!term) return allTags.filter(t => !value.includes(t.name));
|
||||
return allTags.filter(t =>
|
||||
t.name.toLowerCase().includes(term) && !value.includes(t.name)
|
||||
);
|
||||
});
|
||||
|
||||
const showCreateOption = $derived.by(() => {
|
||||
const term = input.trim();
|
||||
if (!term) return false;
|
||||
// Don't show create if it already exists (case insensitive)
|
||||
return !allTags.some(t => t.name.toLowerCase() === term.toLowerCase());
|
||||
});
|
||||
|
||||
function addTag(tagName: string) {
|
||||
if (!value.includes(tagName)) {
|
||||
onChange([...value, tagName]);
|
||||
}
|
||||
input = '';
|
||||
inputElement?.focus();
|
||||
}
|
||||
|
||||
function removeTag(tagName: string) {
|
||||
onChange(value.filter(t => t !== tagName));
|
||||
}
|
||||
|
||||
async function createTag() {
|
||||
const term = input.trim();
|
||||
if (!term) return;
|
||||
|
||||
// Create new tag in store
|
||||
await tagStore.add(term, type);
|
||||
addTag(term);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filteredTags.length > 0) {
|
||||
addTag(filteredTags[0].name);
|
||||
} else if (showCreateOption) {
|
||||
createTag();
|
||||
}
|
||||
} else if (e.key === 'Backspace' && !input && value.length > 0) {
|
||||
// Remove last tag if input is empty
|
||||
removeTag(value[value.length - 1]);
|
||||
} else if (e.key === 'Escape') {
|
||||
isOpen = false;
|
||||
inputElement?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown on click outside
|
||||
function handleClickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!node.contains(e.target as Node)) {
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full" use:handleClickOutside>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 p-2 focus-within:border-accent-500 transition-colors cursor-text"
|
||||
onclick={() => { inputElement?.focus(); isOpen = true; }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#each value as tagName}
|
||||
<TagBadge
|
||||
name={tagName}
|
||||
color={tagStore.getColor(tagName, type)}
|
||||
onRemove={() => removeTag(tagName)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
bind:value={input}
|
||||
onfocus={() => isOpen = true}
|
||||
onkeydown={handleKeydown}
|
||||
{placeholder}
|
||||
class="min-w-[80px] flex-1 bg-transparent text-sm text-surface-100 placeholder-surface-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isOpen && (filteredTags.length > 0 || showCreateOption)}
|
||||
<div
|
||||
transition:fade={{ duration: 100 }}
|
||||
class="absolute left-0 top-full z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-surface-600 bg-surface-800 shadow-xl"
|
||||
>
|
||||
{#each filteredTags as tag}
|
||||
<button
|
||||
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-surface-200 hover:bg-surface-700"
|
||||
onclick={() => addTag(tag.name)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`h-2 w-2 rounded-full bg-${tag.color}`}></span>
|
||||
{tag.name}
|
||||
</div>
|
||||
{#if value.includes(tag.name)}
|
||||
<Check class="h-4 w-4 text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if showCreateOption}
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-accent-400 hover:bg-surface-700"
|
||||
onclick={createTag}
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Create "{input}"
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
198
src/lib/components/tags/TagManager.svelte
Normal file
198
src/lib/components/tags/TagManager.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<script lang="ts">
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import type { VaultTag, VaultType } from '$lib/types';
|
||||
import { X, Search, Trash2, Edit2, Check, Plus } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let activeTab = $state<VaultType>('character');
|
||||
let searchQuery = $state('');
|
||||
let editingId = $state<string | null>(null);
|
||||
let editName = $state('');
|
||||
let editColor = $state('');
|
||||
|
||||
const colors = [
|
||||
'red-500', 'orange-500', 'amber-500', 'yellow-500', 'lime-500',
|
||||
'green-500', 'emerald-500', 'teal-500', 'cyan-500', 'sky-500',
|
||||
'blue-500', 'indigo-500', 'violet-500', 'purple-500', 'fuchsia-500',
|
||||
'pink-500', 'rose-500'
|
||||
];
|
||||
|
||||
const filteredTags = $derived.by(() => {
|
||||
let tags = tagStore.getTagsForType(activeTab);
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
tags = tags.filter(t => t.name.toLowerCase().includes(q));
|
||||
}
|
||||
return tags;
|
||||
});
|
||||
|
||||
function startEdit(tag: VaultTag) {
|
||||
editingId = tag.id;
|
||||
editName = tag.name;
|
||||
editColor = tag.color;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId || !editName.trim()) return;
|
||||
await tagStore.update(editingId, {
|
||||
name: editName.trim(),
|
||||
color: editColor
|
||||
});
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm('Are you sure you want to delete this tag? It will be removed from all vault items.')) {
|
||||
await tagStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!searchQuery.trim()) return;
|
||||
await tagStore.add(searchQuery.trim(), activeTab);
|
||||
searchQuery = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-surface-800 shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-surface-700 p-4">
|
||||
<h2 class="text-lg font-semibold text-surface-100">Manage Tags</h2>
|
||||
<button
|
||||
class="rounded p-1.5 hover:bg-surface-700 text-surface-400 hover:text-surface-200"
|
||||
onclick={onClose}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-surface-700 bg-surface-900/50">
|
||||
<button
|
||||
class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'character' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
|
||||
onclick={() => activeTab = 'character'}
|
||||
>
|
||||
Characters
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'lorebook' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
|
||||
onclick={() => activeTab = 'lorebook'}
|
||||
>
|
||||
Lorebooks
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'scenario' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
|
||||
onclick={() => activeTab = 'scenario'}
|
||||
>
|
||||
Scenarios
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search/Add Bar -->
|
||||
<div class="border-b border-surface-700 p-4 bg-surface-800">
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={`Search or add ${activeTab} tags...`}
|
||||
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-9 pr-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white hover:bg-accent-500 disabled:opacity-50"
|
||||
disabled={!searchQuery.trim() || filteredTags.some(t => t.name.toLowerCase() === searchQuery.toLowerCase())}
|
||||
onclick={handleCreate}
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag List -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2 bg-surface-900">
|
||||
{#each filteredTags as tag (tag.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-800 p-3 hover:border-surface-600 transition-colors"
|
||||
in:fade={{ duration: 150 }}
|
||||
>
|
||||
{#if editingId === tag.id}
|
||||
<!-- Edit Mode -->
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<!-- Color Picker -->
|
||||
<div class="relative group">
|
||||
<div class={`h-6 w-6 rounded-full bg-${editColor} cursor-pointer ring-2 ring-surface-600`}></div>
|
||||
<div class="absolute left-0 top-full z-10 mt-2 hidden w-48 flex-wrap gap-1 rounded-lg border border-surface-600 bg-surface-800 p-2 shadow-xl group-hover:flex">
|
||||
{#each colors as color}
|
||||
<button
|
||||
class={`h-5 w-5 rounded-full bg-${color} hover:ring-2 ring-white`}
|
||||
onclick={() => editColor = color}
|
||||
title={color}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:value={editName}
|
||||
class="flex-1 rounded border border-surface-600 bg-surface-700 px-2 py-1 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
|
||||
autofocus
|
||||
onkeydown={(e) => e.key === 'Enter' && saveEdit()}
|
||||
/>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<button class="rounded p-1 hover:bg-green-500/20 text-green-400" onclick={saveEdit}>
|
||||
<Check class="h-4 w-4" />
|
||||
</button>
|
||||
<button class="rounded p-1 hover:bg-surface-600 text-surface-400" onclick={() => editingId = null}>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={`h-3 w-3 rounded-full bg-${tag.color}`}></div>
|
||||
<span class="font-medium text-surface-200">{tag.name}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="rounded p-1.5 hover:bg-surface-700 text-surface-400 hover:text-surface-200"
|
||||
onclick={() => startEdit(tag)}
|
||||
>
|
||||
<Edit2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 hover:bg-red-500/20 text-surface-400 hover:text-red-400"
|
||||
onclick={() => handleDelete(tag.id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-surface-500">
|
||||
<p>No tags found.</p>
|
||||
<p class="text-sm">Create one above to get started.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
178
src/lib/components/vault/TagFilter.svelte
Normal file
178
src/lib/components/vault/TagFilter.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script lang="ts">
|
||||
import { tagStore } from "$lib/stores/tags.svelte";
|
||||
import type { VaultType } from "$lib/types";
|
||||
import { Filter, Check, X } from "lucide-svelte";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
|
||||
interface Props {
|
||||
selectedTags: string[];
|
||||
logic: "AND" | "OR";
|
||||
type: VaultType;
|
||||
onUpdate: (tags: string[], logic: "AND" | "OR") => void;
|
||||
}
|
||||
|
||||
let { selectedTags, logic, type, onUpdate }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let search = $state("");
|
||||
|
||||
const availableTags = $derived(tagStore.getTagsForType(type));
|
||||
|
||||
const filteredTags = $derived.by(() => {
|
||||
if (!search.trim()) return availableTags;
|
||||
const q = search.toLowerCase();
|
||||
return availableTags.filter((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function toggleTag(tagName: string) {
|
||||
if (selectedTags.includes(tagName)) {
|
||||
onUpdate(
|
||||
selectedTags.filter((t) => t !== tagName),
|
||||
logic,
|
||||
);
|
||||
} else {
|
||||
onUpdate([...selectedTags, tagName], logic);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTags() {
|
||||
onUpdate([], logic);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function toggleLogic() {
|
||||
onUpdate(selectedTags, logic === "AND" ? "OR" : "AND");
|
||||
}
|
||||
|
||||
// Close dropdown on click outside
|
||||
function handleClickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!node.contains(e.target as Node)) {
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClick);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener("click", handleClick);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative z-20" use:handleClickOutside>
|
||||
<button
|
||||
class={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
selectedTags.length > 0
|
||||
? "border-accent-500 bg-accent-500/10 text-accent-400"
|
||||
: "border-surface-600 bg-surface-800 text-surface-400 hover:border-surface-500"
|
||||
}`}
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
>
|
||||
<Filter class="h-3 w-3" />
|
||||
<span class="hidden sm:inline">Tags</span>
|
||||
{#if selectedTags.length > 0}
|
||||
<span
|
||||
class="ml-1 rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-bold"
|
||||
>
|
||||
{selectedTags.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Mobile Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm sm:hidden"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onclick={() => (isOpen = false)}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 100 }}
|
||||
class="
|
||||
fixed left-1/2 top-1/2 z-50 w-72 -translate-x-1/2 -translate-y-1/2 shadow-2xl
|
||||
sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-64 sm:translate-x-0 sm:translate-y-0 sm:shadow-xl
|
||||
rounded-xl border border-surface-600 bg-surface-800 p-3
|
||||
"
|
||||
>
|
||||
<!-- Header / Logic Toggle -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-surface-400">Filter Logic:</span>
|
||||
<button
|
||||
class="flex items-center rounded bg-surface-700 p-0.5"
|
||||
onclick={toggleLogic}
|
||||
>
|
||||
<span
|
||||
class={`rounded px-2 py-0.5 text-[10px] font-bold transition-all ${
|
||||
logic === "AND"
|
||||
? "bg-accent-500 text-white shadow-sm"
|
||||
: "text-surface-400 hover:text-surface-200"
|
||||
}`}
|
||||
>
|
||||
AND
|
||||
</span>
|
||||
<span
|
||||
class={`rounded px-2 py-0.5 text-[10px] font-bold transition-all ${
|
||||
logic === "OR"
|
||||
? "bg-accent-500 text-white shadow-sm"
|
||||
: "text-surface-400 hover:text-surface-200"
|
||||
}`}
|
||||
>
|
||||
OR
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Filter tags..."
|
||||
class="w-full rounded border border-surface-600 bg-surface-700 px-2 py-1.5 text-xs text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tag List -->
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
{#each filteredTags as tag}
|
||||
<button
|
||||
class={`flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? "bg-accent-500/10 text-accent-400"
|
||||
: "text-surface-300 hover:bg-surface-700"
|
||||
}`}
|
||||
onclick={() => toggleTag(tag.name)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{tag.name}</span>
|
||||
</div>
|
||||
{#if selectedTags.includes(tag.name)}
|
||||
<Check class="h-3 w-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredTags.length === 0}
|
||||
<div class="py-2 text-center text-xs text-surface-500">
|
||||
No tags found
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if selectedTags.length > 0}
|
||||
<div class="mt-2 border-t border-surface-700 pt-2">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-1 rounded bg-surface-700 py-1.5 text-xs text-surface-300 hover:bg-surface-600 hover:text-surface-100"
|
||||
onclick={clearTags}
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
import type { VaultCharacter } from '$lib/types';
|
||||
import { Star, Pencil, Trash2, User, Users, Loader2 } from 'lucide-svelte';
|
||||
import { normalizeImageDataUrl } from '$lib/utils/image';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import TagBadge from '$lib/components/tags/TagBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
character: VaultCharacter;
|
||||
|
|
@ -105,6 +107,17 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if character.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each character.tags.slice(0, 3) as tag}
|
||||
<TagBadge name={tag} color={tagStore.getColor(tag, 'character')} />
|
||||
{/each}
|
||||
{#if character.tags.length > 3}
|
||||
<span class="text-xs text-surface-500">+{character.tags.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { characterVault } from '$lib/stores/characterVault.svelte';
|
||||
import { X, User, Users, ImageUp, Loader2 } from 'lucide-svelte';
|
||||
import { normalizeImageDataUrl } from '$lib/utils/image';
|
||||
import TagInput from '$lib/components/tags/TagInput.svelte';
|
||||
|
||||
interface Props {
|
||||
character?: VaultCharacter | null;
|
||||
|
|
@ -23,7 +24,7 @@
|
|||
let relationshipTemplate = $state(character?.relationshipTemplate ?? '');
|
||||
let traits = $state(character?.traits.join(', ') ?? '');
|
||||
let visualDescriptors = $state(character?.visualDescriptors.join(', ') ?? '');
|
||||
let tags = $state(character?.tags.join(', ') ?? '');
|
||||
let tags = $state<string[]>(character?.tags ?? []);
|
||||
let portrait = $state<string | null>(character?.portrait ?? null);
|
||||
|
||||
let saving = $state(false);
|
||||
|
|
@ -44,8 +45,7 @@
|
|||
try {
|
||||
const traitsArray = traits.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const visualDescriptorsArray = visualDescriptors.split(',').map(d => d.trim()).filter(Boolean);
|
||||
const tagsArray = tags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
|
||||
if (isEditing && character) {
|
||||
// Update existing
|
||||
await characterVault.update(character.id, {
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
relationshipTemplate: relationshipTemplate.trim() || null,
|
||||
traits: traitsArray,
|
||||
visualDescriptors: visualDescriptorsArray,
|
||||
tags: tagsArray,
|
||||
tags,
|
||||
portrait,
|
||||
});
|
||||
onSaved?.(characterVault.getById(character.id)!);
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
relationshipTemplate: relationshipTemplate.trim() || null,
|
||||
traits: traitsArray,
|
||||
visualDescriptors: visualDescriptorsArray,
|
||||
tags: tagsArray,
|
||||
tags,
|
||||
portrait,
|
||||
favorite: false,
|
||||
source: 'manual',
|
||||
|
|
@ -330,12 +330,11 @@
|
|||
<!-- Tags -->
|
||||
<div>
|
||||
<label for="tags" class="block text-sm font-medium text-surface-300 mb-1">Tags</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
bind:value={tags}
|
||||
placeholder="Fantasy, Hero, Original (comma-separated)"
|
||||
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
|
||||
<TagInput
|
||||
value={tags}
|
||||
type="character"
|
||||
onChange={(newTags) => tags = newTags}
|
||||
placeholder="Add tags..."
|
||||
/>
|
||||
<p class="mt-1 text-xs text-surface-500">For organizing your vault</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { VaultLorebook, EntryType } from '$lib/types';
|
||||
import { Star, Pencil, Trash2, Archive, Users, MapPin, Box, Flag, Brain, Calendar, Loader2 } from 'lucide-svelte';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import TagBadge from '$lib/components/tags/TagBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
lorebook: VaultLorebook;
|
||||
|
|
@ -118,6 +120,17 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lorebook.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each lorebook.tags.slice(0, 3) as tag}
|
||||
<TagBadge name={tag} color={tagStore.getColor(tag, 'lorebook')} />
|
||||
{/each}
|
||||
{#if lorebook.tags.length > 3}
|
||||
<span class="text-xs text-surface-500">+{lorebook.tags.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
} from 'lucide-svelte';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
import InteractiveLorebookChat from './InteractiveLorebookChat.svelte';
|
||||
import TagInput from '$lib/components/tags/TagInput.svelte';
|
||||
|
||||
interface Props {
|
||||
lorebook: VaultLorebook;
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
// Local state for editing
|
||||
let name = $state(lorebook.name);
|
||||
let description = $state(lorebook.description ?? '');
|
||||
let tags = $state<string[]>([...lorebook.tags]);
|
||||
let entries = $state<VaultLorebookEntry[]>(JSON.parse(JSON.stringify(lorebook.entries))); // Deep copy
|
||||
|
||||
// UI State
|
||||
|
|
@ -78,6 +80,7 @@
|
|||
name,
|
||||
description: description || null,
|
||||
entries,
|
||||
tags,
|
||||
metadata: {
|
||||
...lorebook.metadata,
|
||||
format: lorebook.metadata?.format ?? 'aventura',
|
||||
|
|
@ -110,6 +113,7 @@
|
|||
name,
|
||||
description: description || null,
|
||||
entries,
|
||||
tags,
|
||||
metadata: {
|
||||
...lorebook.metadata,
|
||||
format: lorebook.metadata?.format ?? 'aventura',
|
||||
|
|
@ -256,6 +260,16 @@
|
|||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-300 mb-1">Tags</label>
|
||||
<TagInput
|
||||
value={tags}
|
||||
type="lorebook"
|
||||
onChange={(newTags) => tags = newTags}
|
||||
placeholder="Add tags..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-surface-800 p-4 border border-surface-700">
|
||||
<h4 class="text-sm font-medium text-surface-200 mb-2">Statistics</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
Book,
|
||||
Globe,
|
||||
MapPin,
|
||||
Tags,
|
||||
} from "lucide-svelte";
|
||||
import VaultCharacterCard from "./VaultCharacterCard.svelte";
|
||||
import VaultCharacterForm from "./VaultCharacterForm.svelte";
|
||||
|
|
@ -30,6 +31,9 @@
|
|||
import VaultScenarioCard from "./VaultScenarioCard.svelte";
|
||||
import VaultScenarioEditor from "./VaultScenarioEditor.svelte";
|
||||
import DiscoveryModal from "$lib/components/discovery/DiscoveryModal.svelte";
|
||||
import TagFilter from "./TagFilter.svelte";
|
||||
import TagManager from "$lib/components/tags/TagManager.svelte";
|
||||
import { tagStore } from "$lib/stores/tags.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
// Types
|
||||
|
|
@ -39,6 +43,9 @@
|
|||
let activeTab = $state<VaultTab>(ui.vaultTab);
|
||||
let searchQuery = $state("");
|
||||
let showFavoritesOnly = $state(false);
|
||||
let selectedTags = $state<string[]>([]);
|
||||
let filterLogic = $state<"AND" | "OR">("OR");
|
||||
let showTagManager = $state(false);
|
||||
|
||||
// Character State
|
||||
let charFilterType = $state<VaultCharacterType | "all">("all");
|
||||
|
|
@ -73,6 +80,14 @@
|
|||
chars = chars.filter((c) => c.favorite);
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
if (filterLogic === 'AND') {
|
||||
chars = chars.filter(c => selectedTags.every(tag => c.tags.includes(tag)));
|
||||
} else {
|
||||
chars = chars.filter(c => selectedTags.some(tag => c.tags.includes(tag)));
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
chars = chars.filter(
|
||||
|
|
@ -95,6 +110,14 @@
|
|||
books = books.filter((b) => b.favorite);
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
if (filterLogic === 'AND') {
|
||||
books = books.filter(b => selectedTags.every(tag => b.tags.includes(tag)));
|
||||
} else {
|
||||
books = books.filter(b => selectedTags.some(tag => b.tags.includes(tag)));
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
books = books.filter(
|
||||
|
|
@ -116,6 +139,14 @@
|
|||
items = items.filter((s) => s.favorite);
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
if (filterLogic === 'AND') {
|
||||
items = items.filter(s => selectedTags.every(tag => s.tags.includes(tag)));
|
||||
} else {
|
||||
items = items.filter(s => selectedTags.some(tag => s.tags.includes(tag)));
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
items = items.filter(
|
||||
|
|
@ -134,16 +165,19 @@
|
|||
if (!characterVault.isLoaded) characterVault.load();
|
||||
if (!lorebookVault.isLoaded) lorebookVault.load();
|
||||
if (!scenarioVault.isLoaded) scenarioVault.load();
|
||||
if (!tagStore.isLoaded) tagStore.load();
|
||||
});
|
||||
|
||||
// Sync with UI store
|
||||
$effect(() => {
|
||||
activeTab = ui.vaultTab;
|
||||
selectedTags = []; // Reset tags when tab changes externally
|
||||
});
|
||||
|
||||
// Update UI store when tab changes
|
||||
$effect(() => {
|
||||
ui.setVaultTab(activeTab);
|
||||
selectedTags = []; // Reset tags when tab changes internally
|
||||
});
|
||||
|
||||
// Character Handlers
|
||||
|
|
@ -298,6 +332,15 @@
|
|||
|
||||
<!-- Right Side Actions (Context Sensitive) -->
|
||||
<div class="flex items-center gap-2 -mr-1 sm:mr-0">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 hover:bg-surface-600"
|
||||
onclick={() => (showTagManager = true)}
|
||||
title="Manage Tags"
|
||||
>
|
||||
<Tags class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Tags</span>
|
||||
</button>
|
||||
|
||||
{#if activeTab === "characters"}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 hover:bg-surface-600"
|
||||
|
|
@ -540,6 +583,16 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<TagFilter
|
||||
selectedTags={selectedTags}
|
||||
logic={filterLogic}
|
||||
type={activeTab === "characters" ? "character" : activeTab === "lorebooks" ? "lorebook" : "scenario"}
|
||||
onUpdate={(tags, logic) => {
|
||||
selectedTags = tags;
|
||||
filterLogic = logic;
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-colors shrink-0 {showFavoritesOnly
|
||||
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
|
||||
|
|
@ -547,7 +600,7 @@
|
|||
onclick={() => (showFavoritesOnly = !showFavoritesOnly)}
|
||||
>
|
||||
<Star class="h-3 w-3 {showFavoritesOnly ? 'fill-yellow-400' : ''}" />
|
||||
Favorites
|
||||
<span class="hidden sm:inline">Favorites</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -730,3 +783,8 @@
|
|||
mode={discoveryMode}
|
||||
onClose={() => (showDiscoveryModal = false)}
|
||||
/>
|
||||
|
||||
<!-- Tag Manager Modal -->
|
||||
{#if showTagManager}
|
||||
<TagManager onClose={() => (showTagManager = false)} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { VaultScenario } from '$lib/types';
|
||||
import { Star, Pencil, Trash2, MapPin, Users, MessageSquare, Loader2 } from 'lucide-svelte';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import TagBadge from '$lib/components/tags/TagBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
scenario: VaultScenario;
|
||||
|
|
@ -98,7 +100,7 @@
|
|||
{#if scenario.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each scenario.tags.slice(0, 3) as tag}
|
||||
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-xs text-surface-400">{tag}</span>
|
||||
<TagBadge name={tag} color={tagStore.getColor(tag, 'scenario')} />
|
||||
{/each}
|
||||
{#if scenario.tags.length > 3}
|
||||
<span class="text-xs text-surface-500">+{scenario.tags.length - 3}</span>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
FileText, MapPin, User, ChevronDown, ChevronRight, Search
|
||||
} from 'lucide-svelte';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
import TagInput from '$lib/components/tags/TagInput.svelte';
|
||||
|
||||
interface Props {
|
||||
scenario: VaultScenario;
|
||||
|
|
@ -28,7 +29,6 @@
|
|||
let activeTab = $state<'general' | 'npcs' | 'opening'>('general');
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let tagInput = $state('');
|
||||
|
||||
// Character Selector State
|
||||
let showCharacterSelector = $state(false);
|
||||
|
|
@ -89,19 +89,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Tag Management
|
||||
function addTag() {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !tags.includes(tag)) {
|
||||
tags = [...tags, tag];
|
||||
tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
tags = tags.filter(t => t !== tag);
|
||||
}
|
||||
|
||||
// NPC Management
|
||||
function addNpc() {
|
||||
const newIndex = npcs.length;
|
||||
|
|
@ -282,32 +269,12 @@
|
|||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-300 mb-1">Tags</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
{#each tags as tag}
|
||||
<span class="flex items-center gap-1 rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-200">
|
||||
{tag}
|
||||
<button onclick={() => removeTag(tag)} class="hover:text-white">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag()}
|
||||
class="flex-1 rounded-lg border border-surface-600 bg-surface-800 px-3 py-2 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="Add a tag..."
|
||||
/>
|
||||
<button
|
||||
onclick={addTag}
|
||||
disabled={!tagInput.trim()}
|
||||
class="rounded-lg bg-surface-700 px-3 py-2 text-surface-200 hover:bg-surface-600 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<TagInput
|
||||
value={tags}
|
||||
type="scenario"
|
||||
onChange={(newTags) => tags = newTags}
|
||||
placeholder="Add tags..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import type {
|
|||
VaultCharacterType,
|
||||
VaultLorebook,
|
||||
VaultScenario,
|
||||
VaultTag,
|
||||
VaultType,
|
||||
} from '$lib/types';
|
||||
|
||||
class DatabaseService {
|
||||
|
|
@ -1967,7 +1969,210 @@ private mapEmbeddedImage(row: any): EmbeddedImage {
|
|||
return results.map(this.mapVaultScenario);
|
||||
}
|
||||
|
||||
// ===== Vault Tag Operations =====
|
||||
|
||||
async getVaultTags(type?: VaultType): Promise<VaultTag[]> {
|
||||
const db = await this.getDb();
|
||||
const query = type
|
||||
? 'SELECT * FROM vault_tags WHERE type = ? ORDER BY name ASC'
|
||||
: 'SELECT * FROM vault_tags ORDER BY type ASC, name ASC';
|
||||
const params = type ? [type] : [];
|
||||
|
||||
const results = await db.select<any[]>(query, params);
|
||||
return results.map(this.mapVaultTag);
|
||||
}
|
||||
|
||||
async addVaultTag(tag: VaultTag): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
'INSERT INTO vault_tags (id, name, type, color, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[tag.id, tag.name, tag.type, tag.color, tag.createdAt]
|
||||
);
|
||||
}
|
||||
|
||||
async updateVaultTag(id: string, updates: Partial<VaultTag>): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
// Get current tag first if we're updating the name
|
||||
let oldName: string | null = null;
|
||||
let type: VaultType | null = null;
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
const currentTag = await this.getDb().then(d => d.select<any[]>('SELECT name, type FROM vault_tags WHERE id = ?', [id]));
|
||||
if (currentTag.length > 0) {
|
||||
oldName = currentTag[0].name;
|
||||
type = currentTag[0].type as VaultType;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.name !== undefined) { setClauses.push('name = ?'); values.push(updates.name); }
|
||||
if (updates.color !== undefined) { setClauses.push('color = ?'); values.push(updates.color); }
|
||||
|
||||
if (setClauses.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
await db.execute(`UPDATE vault_tags SET ${setClauses.join(', ')} WHERE id = ?`, values);
|
||||
|
||||
// If name changed, we must update all vault items that use this tag
|
||||
// This is a heavy operation but safe because we use transactions implicitly or just sequence it
|
||||
if (oldName && updates.name && type && oldName !== updates.name) {
|
||||
await this.migrateTagInVaultItems(type, oldName, updates.name);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteVaultTag(id: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
|
||||
// Get the tag first to know its name and type
|
||||
const tagResult = await db.select<any[]>('SELECT name, type FROM vault_tags WHERE id = ?', [id]);
|
||||
if (tagResult.length === 0) return;
|
||||
|
||||
const { name, type } = tagResult[0];
|
||||
|
||||
// Delete definition
|
||||
await db.execute('DELETE FROM vault_tags WHERE id = ?', [id]);
|
||||
|
||||
// Remove from all vault items
|
||||
await this.removeTagFromVaultItems(type, name);
|
||||
}
|
||||
|
||||
// Helper to rename a tag across all vault items
|
||||
private async migrateTagInVaultItems(type: VaultType, oldName: string, newName: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
let table = '';
|
||||
|
||||
if (type === 'character') table = 'character_vault';
|
||||
else if (type === 'lorebook') table = 'lorebook_vault';
|
||||
else if (type === 'scenario') table = 'scenario_vault';
|
||||
|
||||
if (!table) return;
|
||||
|
||||
// We have to read all rows that might contain the tag, update JSON, and write back
|
||||
// A simple REPLACE string might be dangerous if tag name is a substring of another tag
|
||||
const rows = await db.select<{id: string, tags: string}[]>(
|
||||
`SELECT id, tags FROM ${table} WHERE tags LIKE ?`,
|
||||
[`%${oldName}%`]
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const tags = JSON.parse(row.tags) as string[];
|
||||
const index = tags.indexOf(oldName);
|
||||
if (index !== -1) {
|
||||
tags[index] = newName;
|
||||
await db.execute(
|
||||
`UPDATE ${table} SET tags = ? WHERE id = ?`,
|
||||
[JSON.stringify(tags), row.id]
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Database] Failed to migrate tag for ${table} row ${row.id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to remove a tag from all vault items
|
||||
private async removeTagFromVaultItems(type: VaultType, tagName: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
let table = '';
|
||||
|
||||
if (type === 'character') table = 'character_vault';
|
||||
else if (type === 'lorebook') table = 'lorebook_vault';
|
||||
else if (type === 'scenario') table = 'scenario_vault';
|
||||
|
||||
if (!table) return;
|
||||
|
||||
const rows = await db.select<{id: string, tags: string}[]>(
|
||||
`SELECT id, tags FROM ${table} WHERE tags LIKE ?`,
|
||||
[`%${tagName}%`]
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
let tags = JSON.parse(row.tags) as string[];
|
||||
if (tags.includes(tagName)) {
|
||||
tags = tags.filter(t => t !== tagName);
|
||||
await db.execute(
|
||||
`UPDATE ${table} SET tags = ? WHERE id = ?`,
|
||||
[JSON.stringify(tags), row.id]
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Database] Failed to remove tag for ${table} row ${row.id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Populate vault_tags from existing vault data
|
||||
async ensureTagsMigrated(): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
|
||||
// Check if we have any tags
|
||||
const count = await db.select<{c: number}[]>('SELECT COUNT(*) as c FROM vault_tags');
|
||||
// If we already have tags, we assume migration is done or in progress
|
||||
// But we might want to check for new tags that appeared from imports?
|
||||
// For now, let's just do it if empty to seed the system
|
||||
if (count[0].c > 0) return;
|
||||
|
||||
console.log('[Database] Migrating existing tags to vault_tags table...');
|
||||
|
||||
const colors = [
|
||||
'red-500', 'orange-500', 'amber-500', 'yellow-500', 'lime-500',
|
||||
'green-500', 'emerald-500', 'teal-500', 'cyan-500', 'sky-500',
|
||||
'blue-500', 'indigo-500', 'violet-500', 'purple-500', 'fuchsia-500',
|
||||
'pink-500', 'rose-500'
|
||||
];
|
||||
|
||||
const processTable = async (table: string, type: VaultType) => {
|
||||
const rows = await db.select<{tags: string}[]>(`SELECT tags FROM ${table}`);
|
||||
const uniqueTags = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const tags = JSON.parse(row.tags) as string[];
|
||||
tags.forEach(t => uniqueTags.add(t.trim()));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const tagName of uniqueTags) {
|
||||
if (!tagName) continue;
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
// Use crypto.randomUUID() if available, otherwise simple random
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
'INSERT INTO vault_tags (id, name, type, color, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, tagName, type, color, Date.now()]
|
||||
);
|
||||
} catch (e) {
|
||||
// Ignore unique constraint errors
|
||||
console.warn(`[Database] Skipped duplicate tag ${tagName} during migration`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processTable('character_vault', 'character');
|
||||
await processTable('lorebook_vault', 'lorebook');
|
||||
await processTable('scenario_vault', 'scenario');
|
||||
|
||||
console.log('[Database] Tag migration complete');
|
||||
}
|
||||
|
||||
private mapVaultTag(row: any): VaultTag {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type as VaultType,
|
||||
color: row.color,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapVaultScenario(row: any): VaultScenario {
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
|
|
|
|||
68
src/lib/stores/tags.svelte.ts
Normal file
68
src/lib/stores/tags.svelte.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { database } from '$lib/services/database';
|
||||
import type { VaultTag, VaultType } from '$lib/types';
|
||||
|
||||
class TagStore {
|
||||
tags = $state<VaultTag[]>([]);
|
||||
isLoaded = $state(false);
|
||||
|
||||
// Derived views
|
||||
get characterTags() { return this.tags.filter(t => t.type === 'character'); }
|
||||
get lorebookTags() { return this.tags.filter(t => t.type === 'lorebook'); }
|
||||
get scenarioTags() { return this.tags.filter(t => t.type === 'scenario'); }
|
||||
|
||||
async load() {
|
||||
if (this.isLoaded) return;
|
||||
|
||||
// Ensure migration runs once
|
||||
await database.ensureTagsMigrated();
|
||||
|
||||
// Fetch all tags
|
||||
this.tags = await database.getVaultTags();
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
getTagsForType(type: VaultType) {
|
||||
return this.tags.filter(t => t.type === type);
|
||||
}
|
||||
|
||||
getColor(name: string, type: VaultType): string {
|
||||
return this.tags.find(t => t.name === name && t.type === type)?.color ?? 'surface-500';
|
||||
}
|
||||
|
||||
async add(name: string, type: VaultType): Promise<VaultTag> {
|
||||
const colors = [
|
||||
'red-500', 'orange-500', 'amber-500', 'yellow-500', 'lime-500',
|
||||
'green-500', 'emerald-500', 'teal-500', 'cyan-500', 'sky-500',
|
||||
'blue-500', 'indigo-500', 'violet-500', 'purple-500', 'fuchsia-500',
|
||||
'pink-500', 'rose-500'
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
const tag: VaultTag = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
type,
|
||||
color,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
await database.addVaultTag(tag);
|
||||
this.tags.push(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<VaultTag>) {
|
||||
await database.updateVaultTag(id, updates);
|
||||
const index = this.tags.findIndex(t => t.id === id);
|
||||
if (index !== -1) {
|
||||
this.tags[index] = { ...this.tags[index], ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await database.deleteVaultTag(id);
|
||||
this.tags = this.tags.filter(t => t.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
export const tagStore = new TagStore();
|
||||
|
|
@ -840,3 +840,14 @@ export interface TranslationSettings {
|
|||
translateWorldState: boolean; // Translate world state UI elements
|
||||
}
|
||||
|
||||
export type VaultType = 'character' | 'lorebook' | 'scenario';
|
||||
|
||||
export interface VaultTag {
|
||||
id: string;
|
||||
name: string;
|
||||
type: VaultType;
|
||||
color: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue