vault complete

This commit is contained in:
munimunigamer 2026-01-24 03:27:50 -06:00
parent 6e0940d368
commit d4e312ec8d
58 changed files with 4413 additions and 5140 deletions

View file

@ -1,8 +1,14 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "slate"
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "$lib/components",
@ -13,4 +19,4 @@
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/default"
}
}

1423
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,7 @@
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@types/marked": "^5.0.2",
"autoprefixer": "^10.4.20",
@ -43,8 +44,9 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.17",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.6.2",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.0.3"

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,21 @@
<script lang="ts">
import type { DiscoveryCard } from '$lib/services/discovery';
import { Download, Loader2, Check } from 'lucide-svelte';
import { Download, Loader2, Check, Eye } from 'lucide-svelte';
import { Card, CardContent } from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
type NsfwMode = 'disable' | 'blur' | 'enable';
interface Props {
card: DiscoveryCard;
onImport: (card: DiscoveryCard) => void;
onViewDetails?: (card: DiscoveryCard) => void;
isImported?: boolean;
nsfwMode?: NsfwMode;
}
let { card, onImport, isImported = false, nsfwMode = 'disable' }: Props = $props();
let { card, onImport, onViewDetails, isImported = false, nsfwMode = 'disable' }: Props = $props();
// Hide card entirely if NSFW is disabled and card is NSFW
let isHidden = $derived(nsfwMode === 'disable' && card.nsfw);
@ -29,27 +33,32 @@
onImport(card);
}
}
function handleCardClick() {
onViewDetails?.(card);
}
</script>
{#if !isHidden}
<div
class="group relative flex flex-col overflow-hidden rounded-lg border border-surface-600 bg-surface-800 transition-all hover:border-surface-500 hover:shadow-lg"
<Card
class="group overflow-hidden transition-all hover:border-primary/50 hover:shadow-lg h-full flex flex-col cursor-pointer active:scale-[0.98] active:transition-none"
onclick={handleCardClick}
>
<!-- Image -->
<div
class="relative aspect-square w-full overflow-hidden bg-surface-700"
class="relative aspect-square w-full overflow-hidden bg-muted"
>
<div class="absolute inset-0 h-full w-full" class:blur-lg={shouldBlur}>
{#if !imageError && card.avatarUrl}
{#if !imageError && (card.imageUrl || card.avatarUrl)}
<img
src={card.avatarUrl}
src={card.imageUrl || card.avatarUrl}
alt={card.name}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
onerror={handleImageError}
loading="lazy"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-surface-500">
<div class="flex h-full w-full items-center justify-center text-muted-foreground">
<span class="text-4xl">?</span>
</div>
{/if}
@ -57,52 +66,63 @@
<!-- NSFW Badge -->
{#if card.nsfw}
<div class="absolute left-2 top-2 z-20 rounded bg-red-600 px-1.5 py-0.5 text-xs font-medium text-white">
<Badge variant="destructive" class="absolute left-2 top-2 z-20">
NSFW
</div>
</Badge>
{/if}
<!-- Source Badge -->
<div class="absolute right-2 top-2 z-20 rounded bg-surface-900/80 px-1.5 py-0.5 text-xs text-surface-300">
<Badge variant="secondary" class="absolute right-2 top-2 z-20 opacity-90">
{card.source}
</div>
</Badge>
<!-- Imported Badge (Visible always if imported) -->
{#if isImported}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-surface-900/60 backdrop-blur-[1px] z-10">
<div class="flex items-center gap-1.5 rounded-full bg-green-500/20 px-3 py-1 text-sm font-medium text-green-400 border border-green-500/30 shadow-sm">
<Check class="h-4 w-4" />
<span>Imported</span>
</div>
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/60 backdrop-blur-[1px] z-10">
<Badge variant="outline" class="gap-1.5 border-green-500/50 bg-green-500/10 text-green-500">
<Check class="h-3.5 w-3.5" />
Imported
</Badge>
</div>
{/if}
<!-- Hover Actions (Only visible when NOT imported) -->
{#if !isImported}
<div class="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
<button
<div class="absolute inset-0 hidden sm:flex items-center justify-center gap-2 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100 p-4">
<Button
size="icon"
variant="secondary"
onclick={(e) => { e.stopPropagation(); handleCardClick(); }}
class="h-9 w-9 shrink-0"
title="View Details"
>
<Eye class="h-4 w-4" />
</Button>
<Button
size="sm"
onclick={handleImportClick}
class="flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-500"
class="gap-1.5 min-w-[90px]"
>
<Download class="h-4 w-4" />
Import
</button>
</Button>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="line-clamp-1 text-sm font-medium text-surface-100" title={card.name}>
<CardContent class="flex flex-1 flex-col gap-1 p-3">
<h3 class="line-clamp-1 text-sm font-medium leading-none" title={card.name}>
{card.name}
</h3>
{#if card.creator}
<p class="line-clamp-1 text-xs text-surface-400">
<p class="line-clamp-1 text-xs text-muted-foreground">
by {card.creator}
</p>
{/if}
{#if card.description}
<p class="mt-1 line-clamp-2 text-xs text-surface-500">
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
{card.description}
</p>
{/if}
@ -111,17 +131,17 @@
{#if card.tags.length > 0}
<div class="mt-auto flex flex-wrap gap-1 pt-2">
{#each card.tags.slice(0, 3) as tag}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-xs text-surface-400">
<Badge variant="outline" class="text-[10px] px-1 py-0 h-5 font-normal">
{tag}
</span>
</Badge>
{/each}
{#if card.tags.length > 3}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-xs text-surface-400">
<Badge variant="outline" class="text-[10px] px-1 py-0 h-5 font-normal">
+{card.tags.length - 3}
</span>
</Badge>
{/if}
</div>
{/if}
</div>
</div>
</CardContent>
</Card>
{/if}

View file

@ -0,0 +1,304 @@
<script lang="ts">
import type { DiscoveryCard } from "$lib/services/discovery";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import {
Download,
ArrowLeft,
Eye,
Download as DownloadIcon,
AlertTriangle,
ChevronsUpDown,
} from "lucide-svelte";
import * as Alert from "$lib/components/ui/alert";
import { slide } from "svelte/transition";
import { discoveryService } from "$lib/services/discovery";
import { Loader2 } from "lucide-svelte";
interface Props {
card: DiscoveryCard;
onBack: () => void;
onImport: (card: DiscoveryCard) => void;
isImported?: boolean;
nsfwMode?: "disable" | "blur" | "enable";
}
let {
card,
onBack,
onImport,
isImported = false,
nsfwMode = "disable",
}: Props = $props();
let shouldBlur = $derived(nsfwMode === "blur" && card.nsfw);
let imageError = $state(false);
let isRawDataOpen = $state(false);
let detailedCard = $state<DiscoveryCard>(card);
let isLoadingDetails = $state(false);
function handleImageError() {
imageError = true;
}
$effect(() => {
// Reset detailed card when prop changes
detailedCard = card;
// Fetch details if available
const loadDetails = async () => {
isLoadingDetails = true;
try {
const fullCard = await discoveryService.getCardDetails(card);
detailedCard = fullCard;
} catch (e) {
console.error("Failed to load card details", e);
} finally {
isLoadingDetails = false;
}
};
loadDetails();
});
</script>
<div class="flex flex-col h-full w-full bg-background">
<!-- Header (Mobile: Back + Title) -->
<div
class="flex items-center gap-2 p-3 sm:p-4 border-b shrink-0 bg-background z-10 sticky top-0 -mt-2.5 sm:mt-0 pt-1.5 sm:pt-4"
>
<Button
variant="ghost"
size="icon"
onclick={onBack}
class="h-8 w-8 shrink-0 order-2 sm:order-1 sm:-ml-2"
>
<ArrowLeft class="h-5 w-5" />
<span class="sr-only">Back</span>
</Button>
<h2
class="font-semibold text-lg truncate flex-1 text-left order-1 sm:order-2"
>
{card.name}
</h2>
{#if isImported}
<Badge
variant="outline"
class="border-green-500/50 text-green-500 bg-green-500/10 shrink-0 hidden sm:inline-flex order-3"
>
Imported
</Badge>
{/if}
</div>
<!-- Content Area - Native scrolling for better mobile behavior -->
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="flex flex-col md:flex-row gap-6 max-w-5xl mx-auto">
<!-- Image Section -->
<div class="w-full md:w-1/3 shrink-0 space-y-4">
<div
class="relative aspect-square w-full rounded-lg overflow-hidden bg-muted border shadow-sm"
>
<div
class="absolute inset-0 h-full w-full"
class:blur-xl={shouldBlur}
>
{#if !imageError && (card.imageUrl || card.avatarUrl)}
<img
src={card.imageUrl || card.avatarUrl}
alt={card.name}
class="w-full h-full object-cover"
onerror={handleImageError}
/>
{:else}
<div
class="flex h-full w-full items-center justify-center text-muted-foreground"
>
<span class="text-6xl">?</span>
</div>
{/if}
</div>
{#if card.nsfw}
<Badge
variant="destructive"
class="absolute left-2 top-2 z-10 shadow-sm"
>
NSFW
</Badge>
{/if}
<Badge
variant="secondary"
class="absolute right-2 top-2 z-10 opacity-90 shadow-sm"
>
{card.source}
</Badge>
</div>
<!-- Mobile Import Button -->
<div
class="md:hidden sticky bottom-0 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
>
<Button
size="lg"
variant={isImported ? "secondary" : "default"}
class="w-full gap-2 shadow-md"
disabled={isImported}
onclick={() => onImport(detailedCard)}
>
{#if isImported}
Imported
{:else}
<Download class="h-5 w-5" />
Import Card
{/if}
</Button>
</div>
</div>
<!-- Info Section -->
<div class="flex-1 space-y-6 pb-8 min-w-0">
<!-- Metadata -->
<div class="space-y-4">
<div>
<h1 class="text-2xl font-bold hidden md:block">{card.name}</h1>
<div
class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground"
>
{#if card.creator}
<span class="font-medium text-foreground"
>By {card.creator}</span
>
{/if}
{#if card.stats}
<div class="flex items-center gap-3">
{#if card.stats.downloads}
<span class="flex items-center gap-1" title="Downloads">
<DownloadIcon class="h-3.5 w-3.5" />
{card.stats.downloads}
</span>
{/if}
{#if card.stats.views}
<span class="flex items-center gap-1" title="Views">
<Eye class="h-3.5 w-3.5" />
{card.stats.views}
</span>
{/if}
</div>
{/if}
</div>
</div>
<!-- Tags -->
{#if card.tags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each card.tags as tag}
<Badge
variant="secondary"
class="font-normal text-xs px-2 py-0.5"
>
{tag}
</Badge>
{/each}
</div>
{/if}
</div>
<!-- Description -->
<div class="space-y-2">
<h3
class="text-sm font-semibold text-foreground/80 uppercase tracking-wider"
>
Description
</h3>
<div
class="p-4 rounded-lg bg-muted/50 border text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed"
>
{card.description || "No description provided."}
</div>
</div>
<!-- Raw Data Collapsible (Manual Implementation) -->
<div class="space-y-2 border rounded-lg p-1">
<button
class="flex items-center justify-between w-full p-2 text-left hover:bg-muted/50 rounded-md transition-colors"
onclick={() => (isRawDataOpen = !isRawDataOpen)}
>
<div class="flex items-center gap-2">
<h3
class="text-sm font-semibold text-foreground/80 uppercase tracking-wider pl-1"
>
Original Source Data
</h3>
{#if isLoadingDetails}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
{/if}
</div>
<div class="p-1 text-muted-foreground">
<ChevronsUpDown class="h-4 w-4" />
</div>
</button>
{#if isRawDataOpen}
<div
transition:slide={{ duration: 200, axis: "y" }}
class="px-3 pb-3 pt-1 space-y-4"
>
<Alert.Root variant="warning">
<AlertTriangle class="h-4 w-4" />
<Alert.Title>Import Context Warning</Alert.Title>
<Alert.Description>
This is the raw data associated with the card. During import,
some fields might be remapped or formatted to fit the local
schema. This data serves as the source of truth for the import
process.
</Alert.Description>
</Alert.Root>
<div
class="rounded-md bg-muted p-4 overflow-x-auto max-h-[300px] overflow-y-auto"
>
{#if isLoadingDetails}
<div
class="flex items-center justify-center py-8 text-muted-foreground gap-2"
>
<Loader2 class="h-4 w-4 animate-spin" />
<span>Fetching full details...</span>
</div>
{:else}
<pre
class="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-words">{JSON.stringify(
detailedCard,
null,
2,
)}</pre>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Desktop Footer Actions -->
<div
class="hidden md:flex p-4 border-t bg-muted/10 items-center justify-end gap-3 shrink-0"
>
<Button variant="outline" onclick={onBack}>Back</Button>
<Button
variant={isImported ? "secondary" : "default"}
class="gap-2 min-w-[120px]"
disabled={isImported}
onclick={() => onImport(detailedCard)}
>
{#if isImported}
Imported
{:else}
<Download class="h-4 w-4" />
Import
{/if}
</Button>
</div>
</div>

View file

@ -3,13 +3,13 @@
X,
Search,
Loader2,
ChevronDown,
Tag,
Filter,
Check,
EyeOff,
Eye,
Blend,
Globe,
Tag,
} from "lucide-svelte";
import {
discoveryService,
@ -17,10 +17,21 @@
type SearchResult,
} from "$lib/services/discovery";
import DiscoveryCardComponent from "./DiscoveryCard.svelte";
import DiscoveryCardDetails from "./DiscoveryCardDetails.svelte";
import { characterVault } from "$lib/stores/characterVault.svelte";
import { lorebookVault } from "$lib/stores/lorebookVault.svelte";
import { scenarioVault } from "$lib/stores/scenarioVault.svelte";
import * as ResponsiveModal from "$lib/components/ui/responsive-modal";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Badge } from "$lib/components/ui/badge";
import * as Select from "$lib/components/ui/select";
import * as ToggleGroup from "$lib/components/ui/toggle-group";
import * as Popover from "$lib/components/ui/popover";
import * as Command from "$lib/components/ui/command";
import { cn } from "$lib/utils/cn";
interface Props {
isOpen: boolean;
mode: "character" | "lorebook" | "scenario";
@ -29,11 +40,9 @@
let { isOpen, mode, onClose }: Props = $props();
// NSFW mode type
type NsfwMode = "disable" | "blur" | "enable";
const NSFW_MODE_STORAGE_KEY = "aventura:discovery:nsfwMode";
// Load persisted NSFW mode
function loadNsfwMode(): NsfwMode {
if (typeof localStorage !== "undefined") {
const stored = localStorage.getItem(NSFW_MODE_STORAGE_KEY);
@ -44,9 +53,8 @@
return "disable";
}
// State
let searchQuery = $state("");
let activeProviderId = $state("all"); // 'all' for Search All, or provider id
let activeProviderId = $state("all");
let results = $state<DiscoveryCard[]>([]);
let isLoading = $state(false);
let hasMore = $state(false);
@ -54,8 +62,8 @@
let errorMessage = $state<string | null>(null);
let nsfwMode = $state<NsfwMode>(loadNsfwMode());
let hasInitialSearched = $state(false);
let selectedCard = $state<DiscoveryCard | null>(null);
// Track known imported items (URLs)
let importedUrls = $derived.by(() => {
const urls = new Set<string>();
if (mode === "character") {
@ -74,30 +82,20 @@
return urls;
});
// Persist settings
$effect(() => {
if (typeof localStorage !== "undefined") {
localStorage.setItem(NSFW_MODE_STORAGE_KEY, nsfwMode);
}
});
// Tag filtering
let selectedTags = $state<string[]>([]);
let tagInput = $state("");
let showTagDropdown = $state(false);
let showProviderDropdown = $state(false);
let availableTags = $state<string[]>([]);
let isLoadingTags = $state(false);
// Derived
let providers = $derived(discoveryService.getProviders(mode));
let activeProviderName = $derived(
activeProviderId === "all"
? "All Sources"
: providers.find((p) => p.id === activeProviderId)?.name || "Unknown",
);
// Filter suggestions from available tags based on input
let tagSuggestions = $derived(
tagInput.trim()
? availableTags
@ -106,14 +104,14 @@
t.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.includes(t),
)
.slice(0, 15)
.slice(0, 30)
: [],
);
// Popular tags (subset of available tags for quick selection)
let popularTags = $derived(availableTags.slice(0, 18));
let popularTags = $derived(
availableTags.slice(0, 20).filter((t) => !selectedTags.includes(t)),
);
// Fetch tags when modal opens or provider changes
async function loadTags() {
isLoadingTags = true;
try {
@ -131,33 +129,26 @@
}
}
// Load tags when modal opens and trigger initial search
$effect(() => {
if (isOpen) {
loadTags();
// Trigger initial search when modal first opens
if (!hasInitialSearched) {
hasInitialSearched = true;
handleSearch();
}
} else {
// Reset when modal closes
hasInitialSearched = false;
}
});
// Reload tags when provider changes
$effect(() => {
// Create dependency on activeProviderId
const _providerId = activeProviderId;
if (isOpen) {
loadTags();
}
});
// Reset state when mode changes
$effect(() => {
// Access mode to create dependency
const _mode = mode;
results = [];
currentPage = 1;
@ -211,7 +202,6 @@
let result: SearchResult;
if (activeProviderId === "all") {
// Use loadMoreAll for aggregated pagination
result = await discoveryService.loadMoreAll(mode, 48);
} else {
const searchOptions = {
@ -260,29 +250,6 @@
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
handleSearch();
}
if (e.key === "Escape") {
if (showProviderDropdown) {
showProviderDropdown = false;
} else if (showTagDropdown) {
showTagDropdown = false;
} else {
onClose();
}
}
}
function selectProvider(providerId: string) {
activeProviderId = providerId;
showProviderDropdown = false;
results = [];
hasMore = false;
errorMessage = null;
}
function toggleTag(tag: string) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter((t) => t !== tag);
@ -306,431 +273,344 @@
function clearTags() {
selectedTags = [];
}
function handleViewDetails(card: DiscoveryCard) {
selectedCard = card;
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onclick={onClose}
<ResponsiveModal.Root open={isOpen} onOpenChange={(v) => !v && onClose()}>
<ResponsiveModal.Content
class="w-full sm:w-[calc(100%-2rem)] max-w-7xl h-[85vh] p-0 gap-0 flex flex-col overflow-hidden"
>
<!-- Modal -->
<div
class="card w-full max-w-6xl h-[85vh] overflow-hidden flex flex-col shadow-2xl"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-surface-700 px-2 py-2 sm:px-4 sm:py-3 flex-shrink-0 bg-surface-800 -mt-2"
{#if selectedCard}
<DiscoveryCardDetails
card={selectedCard}
onBack={() => (selectedCard = null)}
onImport={handleImport}
isImported={selectedCard &&
importedUrls.has(selectedCard.imageUrl || selectedCard.avatarUrl)}
{nsfwMode}
/>
{:else}
<ResponsiveModal.Header
class="px-4 py-3 border-b shrink-0 text-center sm:text-left"
>
<h2
class="text-base sm:text-lg font-semibold text-surface-100 ml-1 sm:ml-0"
<ResponsiveModal.Title
class="flex items-center mt-2 sm:mt-0 gap-2 justify-center sm:justify-start"
>
Browse {mode === "character"
? "Characters"
: mode === "lorebook"
? "Lorebooks"
: "Scenarios"}
</h2>
<button
onclick={onClose}
class="btn-ghost rounded-lg p-1.5 hover:bg-surface-700 transition-colors -mr-1"
>
<X class="h-4 w-4" />
</button>
</div>
</ResponsiveModal.Title>
<ResponsiveModal.Description class="sr-only">
Find and import new {mode}s from online sources.
</ResponsiveModal.Description>
</ResponsiveModal.Header>
<!-- Controls Bar -->
<div
class="relative z-30 flex flex-col gap-2 border-b border-surface-700 px-2 py-2 sm:px-4 sm:py-3 bg-surface-800/50 flex-shrink-0"
>
<!-- Mobile: Search on top -->
<div class="flex gap-2 sm:hidden">
<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}
onkeydown={handleKeyDown}
placeholder="Search..."
class="w-full rounded-lg border border-surface-600 bg-surface-800 py-2 pl-10 pr-4 text-sm text-surface-100 placeholder-surface-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<button
onclick={handleSearch}
disabled={isLoading}
class="flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-500 disabled:opacity-50"
<div class="flex flex-col border-b bg-muted/20">
<div class="flex flex-col gap-4 p-4">
<div
class="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Search class="h-4 w-4" />
{/if}
</button>
</div>
<!-- Filters row -->
<div
class="flex items-center gap-2 sm:overflow-visible sm:flex-wrap sm:gap-3"
>
<!-- Provider Dropdown -->
<div class="relative flex-shrink-0">
<button
onclick={() => {
showProviderDropdown = !showProviderDropdown;
showTagDropdown = false;
}}
class="flex items-center gap-1.5 sm:gap-2 rounded-lg border border-surface-600 bg-surface-800 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm text-surface-200 transition-colors hover:border-surface-500 hover:bg-surface-700 whitespace-nowrap"
<div
class="flex items-center gap-2 w-full sm:w-auto overflow-x-auto sm:overflow-visible pb-1 sm:pb-0 scrollbar-hide sm:flex-1 justify-between sm:justify-normal"
>
{#if activeProviderId !== "all"}
{@const provider = providers.find(
(p) => p.id === activeProviderId,
)}
{#if provider?.icon}
<img src={provider.icon} alt="" class="h-4 w-4 rounded" />
{/if}
{/if}
<span class="hidden sm:inline">{activeProviderName}</span>
<span class="sm:hidden"
>{activeProviderId === "all"
? "All"
: providers
.find((p) => p.id === activeProviderId)
?.name?.slice(0, 8) || "Source"}</span
>
<ChevronDown class="h-3.5 w-3.5 sm:h-4 sm:w-4 text-surface-400" />
</button>
{#if showProviderDropdown}
<div
class="absolute left-0 top-full z-40 mt-1 min-w-[180px] rounded-lg border border-surface-600 bg-surface-800 py-1 shadow-xl"
>
<!-- All Sources option -->
<button
onclick={() => selectProvider("all")}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-700"
class:bg-surface-700={activeProviderId === "all"}
class:text-primary-400={activeProviderId === "all"}
class:text-surface-200={activeProviderId !== "all"}
>
<Search class="h-4 w-4" />
<span>All Sources</span>
{#if activeProviderId === "all"}
<Check class="ml-auto h-4 w-4" />
{/if}
</button>
<div class="my-1 border-t border-surface-700"></div>
{#each providers as provider}
<button
onclick={() => selectProvider(provider.id)}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-700"
class:bg-surface-700={activeProviderId === provider.id}
class:text-primary-400={activeProviderId === provider.id}
class:text-surface-200={activeProviderId !== provider.id}
>
{#if provider.icon}
<img src={provider.icon} alt="" class="h-4 w-4 rounded" />
{:else}
<div class="h-4 w-4 rounded bg-surface-600"></div>
{/if}
<span>{provider.name}</span>
{#if activeProviderId === provider.id}
<Check class="ml-auto h-4 w-4" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Tag Filter Button -->
<div class="relative flex-shrink-0">
<button
onclick={() => {
showTagDropdown = !showTagDropdown;
showProviderDropdown = false;
}}
class="flex items-center gap-1.5 sm:gap-2 rounded-lg border border-surface-600 bg-surface-800 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm transition-colors hover:border-surface-500 hover:bg-surface-700 whitespace-nowrap"
class:border-primary-500={selectedTags.length > 0}
class:text-primary-400={selectedTags.length > 0}
class:text-surface-300={selectedTags.length === 0}
>
<Filter class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline"
>Tags{selectedTags.length > 0
? ` (${selectedTags.length})`
: ""}</span
>
<span class="sm:hidden"
>{selectedTags.length > 0 ? selectedTags.length : ""}</span
>
<ChevronDown class="h-3.5 w-3.5 sm:h-4 sm:w-4 text-surface-400" />
</button>
{#if showTagDropdown}
<div
class="absolute left-0 top-full z-10 mt-1 w-72 rounded-lg border border-surface-600 bg-surface-800 p-3 shadow-xl"
>
<!-- Custom tag input -->
<div class="mb-3">
<div class="relative flex items-center">
<input
type="text"
bind:value={tagInput}
onkeydown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (tagSuggestions.length > 0) {
toggleTag(tagSuggestions[0]);
tagInput = "";
} else {
addCustomTag();
}
}
}}
placeholder="Type to search tags..."
class="w-full rounded-lg border border-surface-600 bg-surface-900 px-3 py-2 pr-10 text-sm text-surface-200 placeholder-surface-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
<button
onclick={addCustomTag}
disabled={!tagInput.trim()}
class="absolute right-1.5 rounded-md bg-surface-800 p-1.5 text-surface-400 transition-colors hover:bg-surface-700 hover:text-primary-400 disabled:opacity-0"
title="Add tag"
>
<Check class="h-4 w-4" />
</button>
<!-- Autocomplete Dropdown -->
{#if tagSuggestions.length > 0}
<div class="min-w-[140px] sm:w-[180px] flex-shrink-0">
<Select.Root type="single" bind:value={activeProviderId}>
<Select.Trigger class="h-9 w-full">
{#if activeProviderId === "all"}
<div
class="absolute left-0 right-0 top-full z-20 mt-1 max-h-48 overflow-y-auto rounded-lg border border-surface-600 bg-surface-800 shadow-xl"
class="flex items-center gap-2 text-muted-foreground min-w-0"
>
{#each tagSuggestions as suggestion}
<button
onclick={() => {
toggleTag(suggestion);
tagInput = "";
}}
class="w-full px-3 py-2 text-left text-sm text-surface-200 hover:bg-surface-700 hover:text-white"
>
{suggestion}
</button>
{/each}
<Globe class="h-4 w-4 shrink-0" />
<span class="text-foreground truncate">All Sources</span
>
</div>
{:else}
{@const p = providers.find(
(p) => p.id === activeProviderId,
)}
<div class="flex items-center gap-2 min-w-0">
{#if p?.icon}
<img
src={p.icon}
alt=""
class="h-4 w-4 rounded shrink-0"
/>
{:else}
<Globe class="h-4 w-4 shrink-0" />
{/if}
<span class="truncate">{p?.name || "Unknown"}</span>
</div>
{/if}
</div>
</div>
<!-- Selected tags -->
{#if selectedTags.length > 0}
<div class="mb-3">
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-medium text-surface-400"
>Selected</span
>
<button
onclick={clearTags}
class="text-xs text-surface-500 hover:text-surface-300"
>Clear all</button
>
</div>
<div class="flex flex-wrap gap-1.5">
{#each selectedTags as tag}
<button
onclick={() => removeTag(tag)}
class="flex items-center gap-1 rounded-full bg-primary-600/20 px-2 py-0.5 text-xs text-primary-400 transition-colors hover:bg-primary-600/30"
>
<Tag class="h-3 w-3" />
{tag}
<X class="h-3 w-3" />
</button>
{/each}
</div>
</div>
{/if}
<!-- Popular tags -->
<div>
<div class="mb-1.5 flex items-center gap-2">
<span class="text-xs font-medium text-surface-400"
>Popular Tags</span
>
{#if isLoadingTags}
<Loader2 class="h-3 w-3 animate-spin text-surface-500" />
{/if}
</div>
{#if popularTags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each popularTags as tag}
<button
onclick={() => toggleTag(tag)}
class="rounded-full px-2 py-0.5 text-xs transition-colors"
class:bg-primary-600={selectedTags.includes(tag)}
class:text-white={selectedTags.includes(tag)}
class:bg-surface-700={!selectedTags.includes(tag)}
class:text-surface-300={!selectedTags.includes(tag)}
class:hover:bg-surface-600={!selectedTags.includes(
tag,
)}
>
{tag}
</button>
{/each}
</div>
{:else if !isLoadingTags}
<p class="text-xs text-surface-500">No tags available</p>
{/if}
</div>
</Select.Trigger>
<Select.Content>
<Select.Item value="all">
<Globe class="mr-2 h-4 w-4" />
All Sources
</Select.Item>
{#each providers as provider}
<Select.Item value={provider.id}>
{#if provider.icon}
<img
src={provider.icon}
alt=""
class="mr-2 h-4 w-4 rounded"
/>
{:else}
<Globe class="mr-2 h-4 w-4" />
{/if}
{provider.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
<!-- Divider -->
<div class="h-6 w-px bg-surface-700 mx-1 hidden sm:block"></div>
<div class="flex-shrink-0">
<Popover.Root bind:open={showTagDropdown}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
size="sm"
{...props}
class={cn(
"h-9 px-3",
selectedTags.length > 0 &&
"border-primary text-primary bg-primary/5",
)}
>
<Filter class="h-4 w-4" />
{#if selectedTags.length > 0}
<span class="ml-1.5 text-xs font-medium tabular-nums">
{selectedTags.length}
</span>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0 w-[300px]" align="start">
<Command.Root shouldFilter={false}>
<Command.Input
placeholder="Search tags..."
bind:value={tagInput}
onkeydown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (tagSuggestions.length > 0) {
toggleTag(tagSuggestions[0]);
tagInput = "";
} else {
addCustomTag();
}
}
}}
/>
<Command.List>
<Command.Empty>
{#if tagInput}
<button
class="w-full text-left px-4 py-2 text-sm"
onclick={addCustomTag}
>
Add "{tagInput}"
</button>
{:else}
No tags found.
{/if}
</Command.Empty>
{#if tagSuggestions.length > 0}
<Command.Group heading="Suggestions">
{#each tagSuggestions as tag}
<Command.Item
value={tag}
onSelect={() => {
toggleTag(tag);
tagInput = "";
}}
>
<div
class="mr-2 flex h-4 w-4 items-center justify-center opacity-0"
class:opacity-100={selectedTags.includes(tag)}
>
<Check class="h-4 w-4" />
</div>
{tag}
</Command.Item>
{/each}
</Command.Group>
{/if}
{#if popularTags.length > 0 && !tagInput}
<Command.Group heading="Popular">
{#each popularTags as tag}
<Command.Item
value={tag}
onSelect={() => toggleTag(tag)}
>
<div
class="mr-2 flex h-4 w-4 items-center justify-center opacity-0"
class:opacity-100={selectedTags.includes(tag)}
>
<Check class="h-4 w-4" />
</div>
{tag}
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
<div
class="hidden sm:block w-px h-6 bg-border mx-1 shrink-0"
></div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-medium text-muted-foreground"
>NSFW:</span
>
<ToggleGroup.Root
type="single"
bind:value={nsfwMode}
class="bg-muted p-1 rounded-lg gap-0 h-9 border"
variant="default"
>
<ToggleGroup.Item
value="disable"
class="h-7 rounded-md px-2 text-xs data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm text-muted-foreground hover:bg-transparent hover:text-foreground transition-all flex items-center gap-1.5"
title="Hide NSFW"
>
<EyeOff class="h-3.5 w-3.5" />
<span class="hidden lg:inline">Hide</span>
</ToggleGroup.Item>
<ToggleGroup.Item
value="blur"
class="h-7 rounded-md px-2 text-xs data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm text-muted-foreground hover:bg-transparent hover:text-foreground transition-all flex items-center gap-1.5"
title="Blur NSFW"
>
<Blend class="h-3.5 w-3.5" />
<span class="hidden lg:inline">Blur</span>
</ToggleGroup.Item>
<ToggleGroup.Item
value="enable"
class="h-7 rounded-md px-2 text-xs data-[state=on]:bg-red-500/10 data-[state=on]:text-red-600 data-[state=on]:shadow-sm text-muted-foreground hover:bg-transparent hover:text-red-500 transition-all flex items-center gap-1.5"
title="Show NSFW"
>
<Eye class="h-3.5 w-3.5" />
<span class="hidden lg:inline">Show</span>
</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
</div>
<!-- NSFW Mode Selector -->
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-xs font-medium text-surface-400">NSFW:</span>
<div
class="flex items-center gap-0.5 rounded-lg border border-surface-600 bg-surface-800 p-0.5"
class="hidden sm:flex items-center w-[250px] lg:w-[300px] shrink-0"
>
<button
onclick={() => (nsfwMode = "disable")}
class="flex items-center justify-center gap-0 rounded-md px-2 py-1.5 text-xs font-medium transition-colors"
class:bg-surface-600={nsfwMode === "disable"}
class:text-surface-100={nsfwMode === "disable"}
class:text-surface-400={nsfwMode !== "disable"}
class:hover:text-surface-200={nsfwMode !== "disable"}
title="Hide NSFW content"
>
<EyeOff class="h-3.5 w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">Hide</span>
</button>
<button
onclick={() => (nsfwMode = "blur")}
class="flex items-center justify-center gap-0 rounded-md px-2 py-1.5 text-xs font-medium transition-colors"
class:bg-amber-600={nsfwMode === "blur"}
class:text-white={nsfwMode === "blur"}
class:text-surface-400={nsfwMode !== "blur"}
class:hover:text-amber-400={nsfwMode !== "blur"}
title="Blur NSFW images"
>
<Blend class="h-3.5 w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">Blur</span>
</button>
<button
onclick={() => (nsfwMode = "enable")}
class="flex items-center justify-center gap-0 rounded-md px-2 py-1.5 text-xs font-medium transition-colors"
class:bg-red-600={nsfwMode === "enable"}
class:text-white={nsfwMode === "enable"}
class:text-surface-400={nsfwMode !== "enable"}
class:hover:text-red-400={nsfwMode !== "enable"}
title="Show NSFW content"
>
<Eye class="h-3.5 w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">Show</span>
</button>
</div>
</div>
<!-- Spacer -->
<div class="flex-1 hidden sm:block"></div>
<!-- Search Bar (desktop only) -->
<div class="hidden sm:flex gap-2">
<div class="relative">
<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}
onkeydown={handleKeyDown}
<Input
placeholder="Search..."
class="w-48 rounded-lg border border-surface-600 bg-surface-800 py-2 pl-10 pr-4 text-sm text-surface-100 placeholder-surface-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 sm:w-64"
bind:value={searchQuery}
onkeydown={(e) => e.key === "Enter" && handleSearch()}
class="h-9 rounded-r-none border-r-0 focus-visible:ring-0 focus-visible:border-primary focus-visible:z-10"
/>
<Button
onclick={handleSearch}
disabled={isLoading}
size="icon"
variant="outline"
class="h-9 w-9 rounded-l-none border-l bg-muted/50 hover:bg-muted shrink-0"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Search class="h-4 w-4" />
{/if}
</Button>
</div>
<button
onclick={handleSearch}
disabled={isLoading}
class="flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-500 disabled:opacity-50"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Search class="h-4 w-4" />
{/if}
Search
</button>
</div>
<div class="flex gap-2 sm:hidden">
<div class="flex flex-1 items-center">
<Input
placeholder="Search..."
bind:value={searchQuery}
onkeydown={(e) => e.key === "Enter" && handleSearch()}
class="h-9 rounded-r-none border-r-0 focus-visible:ring-0 focus-visible:border-primary focus-visible:z-10"
/>
<Button
onclick={handleSearch}
disabled={isLoading}
size="icon"
variant="outline"
class="h-9 w-9 rounded-l-none border-l bg-muted/50 hover:bg-muted shrink-0"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Search class="h-4 w-4" />
{/if}
</Button>
</div>
</div>
{#if selectedTags.length > 0}
<div
class="flex flex-wrap items-center gap-2 text-sm pt-3 border-t"
>
{#each selectedTags as tag}
<Badge
variant="secondary"
class="gap-1.5 pl-2 pr-1.5 h-7 items-center font-normal"
>
{tag}
<Button
variant="ghost"
size="icon"
class="h-4 w-4 hover:bg-transparent hover:text-destructive p-0"
onclick={() => removeTag(tag)}
>
<X class="h-3 w-3" />
</Button>
</Badge>
{/each}
<Button
variant="ghost"
size="sm"
class="h-7 text-xs px-2 hover:text-destructive"
onclick={clearTags}
>
Clear all
</Button>
</div>
{/if}
</div>
<!-- Close scrollable row -->
</div>
<!-- Active Tags Display (when tags are selected) -->
{#if selectedTags.length > 0}
<div
class="flex items-center gap-2 border-b border-surface-700 bg-surface-800/50 px-4 py-2"
>
<span class="text-xs text-surface-500">Filtering by:</span>
<div class="flex flex-wrap gap-1.5">
{#each selectedTags as tag}
<span
class="flex items-center gap-1 rounded-full bg-primary-600/20 px-2 py-0.5 text-xs text-primary-400"
>
<Tag class="h-3 w-3" />
{tag}
<button
onclick={() => removeTag(tag)}
class="hover:text-primary-200"
>
<X class="h-3 w-3" />
</button>
</span>
{/each}
</div>
<button
onclick={clearTags}
class="ml-2 text-xs text-surface-500 hover:text-surface-300"
>Clear all</button
<div class="flex-1 overflow-y-auto p-4 bg-muted/5">
{#if errorMessage}
<div
class="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive mb-4"
>
</div>
{/if}
{errorMessage}
</div>
{/if}
<!-- Messages -->
{#if errorMessage}
<div
class="mx-4 mt-3 rounded-lg border border-red-600/50 bg-red-900/20 px-4 py-2 text-sm text-red-400"
>
{errorMessage}
</div>
{/if}
<!-- Results Grid -->
<div class="flex-1 overflow-y-auto p-2 sm:p-4">
{#if results.length === 0 && !isLoading}
<div
class="flex h-full flex-col items-center justify-center text-surface-500"
class="flex h-full flex-col items-center justify-center text-muted-foreground p-8"
>
<Search class="mb-2 h-12 w-12 opacity-50" />
<p>
Search to discover {mode === "character"
? "characters"
: mode === "lorebook"
? "lorebooks"
: "scenarios"}
<Search class="mb-4 h-12 w-12 opacity-20" />
<p class="text-lg font-medium">No results found</p>
<p class="text-sm opacity-70">
Try adjusting your search terms or filters.
</p>
{#if activeProviderId === "all"}
<p class="mt-1 text-xs text-surface-600">
Searching across all available sources
</p>
{/if}
</div>
{:else}
<div
@ -740,29 +620,30 @@
<DiscoveryCardComponent
{card}
onImport={handleImport}
onViewDetails={handleViewDetails}
isImported={importedUrls.has(card.imageUrl || card.avatarUrl)}
{nsfwMode}
/>
{/each}
</div>
<!-- Load More -->
{#if hasMore}
<div class="mt-6 flex justify-center">
<button
<div class="mt-8 flex justify-center pb-4">
<Button
variant="outline"
onclick={loadMore}
disabled={isLoading}
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-800 px-6 py-2 text-surface-300 transition-colors hover:bg-surface-700 disabled:opacity-50"
class="min-w-[150px]"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Load More
</button>
</Button>
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
{/if}
</ResponsiveModal.Content>
</ResponsiveModal.Root>

View file

@ -1,37 +1,68 @@
<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';
import { tagStore } from "$lib/stores/tags.svelte";
import type { VaultTag, VaultType } from "$lib/types";
import { Trash2, Edit2, Check, Plus } from "lucide-svelte";
import * as ResponsiveModal from "$lib/components/ui/responsive-modal";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "$lib/components/ui/tabs";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils/cn";
import { fade } from "svelte/transition";
interface Props {
onClose: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
let { onClose }: Props = $props();
let { open, onOpenChange }: Props = $props();
let activeTab = $state<VaultType>('character');
let searchQuery = $state('');
let activeTab = $state<VaultType>("character");
let searchQuery = $state("");
let editingId = $state<string | null>(null);
let editName = $state('');
let editColor = $state('');
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'
"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));
tags = tags.filter((t) => t.name.toLowerCase().includes(q));
}
return tags;
});
const canCreate = $derived(
searchQuery.trim() &&
!filteredTags.some(
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
),
);
function startEdit(tag: VaultTag) {
editingId = tag.id;
editName = tag.name;
@ -40,15 +71,19 @@
async function saveEdit() {
if (!editingId || !editName.trim()) return;
await tagStore.update(editingId, {
name: editName.trim(),
color: editColor
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.')) {
if (
confirm(
"Are you sure you want to delete this tag? It will be removed from all vault items.",
)
) {
await tagStore.delete(id);
}
}
@ -56,143 +91,140 @@
async function handleCreate() {
if (!searchQuery.trim()) return;
await tagStore.add(searchQuery.trim(), activeTab);
searchQuery = '';
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>
<ResponsiveModal.Root bind:open {onOpenChange}>
<ResponsiveModal.Content class="max-w-2xl flex flex-col" style="height: 500px;">
<ResponsiveModal.Header title="Manage Tags" />
<!-- 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'}
<div class="flex flex-col gap-4 p-4 flex-1 overflow-hidden">
<!-- Tabs -->
<Tabs
value={activeTab}
onValueChange={(v) => (activeTab = v as VaultType)}
>
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>
<TabsList class="grid w-full grid-cols-3">
<TabsTrigger value="character">Characters</TabsTrigger>
<TabsTrigger value="lorebook">Lorebooks</TabsTrigger>
<TabsTrigger value="scenario">Scenarios</TabsTrigger>
</TabsList>
</Tabs>
<!-- Search/Add Bar -->
<div class="border-b border-surface-700 p-4 bg-surface-800">
<div class="flex gap-2">
<!-- Search/Add Bar -->
<div class="flex gap-2 flex-shrink-0">
<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"
<Input
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()}
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())}
<Button
icon={Plus}
label="Add"
disabled={!canCreate}
onclick={handleCreate}
>
<Plus class="h-4 w-4" />
Add
</button>
/>
</div>
<!-- Tag List -->
<div class="overflow-y-auto pr-2 flex-1">
{#if filteredTags.length > 0}
<div class="space-y-2 pb-4">
{#each filteredTags as tag (tag.id)}
<div
class={cn(
"group flex items-center justify-between rounded-lg border bg-muted/40 p-2 hover:bg-muted/60 transition-colors",
editingId === tag.id && "border-primary",
)}
in:fade={{ duration: 150 }}
>
{#if editingId === tag.id}
<!-- Edit Mode -->
<div class="flex flex-1 items-center gap-3">
<!-- Color Picker -->
<div class="relative">
<div
class={`h-6 w-6 rounded-full bg-${editColor} cursor-pointer ring-2 ring-muted`}
></div>
<div
class="absolute left-0 top-full z-10 mt-2 hidden w-48 flex-wrap gap-1 rounded-lg border bg-background 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 h-8"
onkeydown={(e) => e.key === "Enter" && saveEdit()}
/>
<div class="flex gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-green-500 hover:text-green-600 hover:bg-green-500/10"
onclick={saveEdit}
>
<Check class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => (editingId = null)}
>
<Trash2 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">{tag.name}</span>
</div>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => startEdit(tag)}
>
<Edit2 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => handleDelete(tag.id)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="flex h-full flex-col items-center justify-center -mt-2 text-muted-foreground">
<p>No tags found.</p>
<p class="text-sm">Create one above to get started.</p>
</div>
{/if}
</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>
</ResponsiveModal.Content>
</ResponsiveModal.Root>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn("text-sm [&_p]:leading-relaxed", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
level = 5,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
level?: 1 | 2 | 3 | 4 | 5 | 6;
} = $props();
</script>
<div
role="heading"
aria-level={level}
bind:this={ref}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,39 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div bind:this={ref} class={cn(alertVariants({ variant }), className)} {...restProps} role="alert">
{@render children?.()}
</div>

View file

@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View file

@ -13,13 +13,14 @@
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"text-foreground hover:text-destructive",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
text: "text-foreground hover:text-accent",
},
size: {
default: "h-10 px-4 py-2",

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
import type { ClassValue } from "clsx";
let {
ref = $bindable(null),
class: className,
...restProps
}: CollapsiblePrimitive.ContentProps & { class?: ClassValue } = $props();
</script>
<CollapsiblePrimitive.Content
bind:ref
class={cn(
"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className
)}
{...restProps}
>
<slot />
</CollapsiblePrimitive.Content>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
import type { ClassValue } from "clsx";
let {
ref = $bindable(null),
class: className,
...restProps
}: CollapsiblePrimitive.TriggerProps & { class?: ClassValue } = $props();
</script>
<CollapsiblePrimitive.Trigger
bind:ref
class={cn(
"flex items-center justify-center gap-2 text-sm font-medium transition-colors [&[data-state=open]>text-accent-400]",
className
)}
{...restProps}
>
<slot />
</CollapsiblePrimitive.Trigger>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
import type { ClassValue } from "clsx";
let {
ref = $bindable(null),
class: className,
...restProps
}: CollapsiblePrimitive.RootProps & { class?: ClassValue } = $props();
</script>
<CollapsiblePrimitive.Root
bind:ref
class={cn(className)}
{...restProps}
/>

View file

@ -0,0 +1,12 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Trigger,
Content,
Root as Collapsible,
Trigger as CollapsibleTrigger,
Content as CollapsibleContent,
};

View file

@ -1,5 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import {
Dialog as DialogPrimitive,
type WithoutChildrenOrChild,
} from "bits-ui";
import X from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
@ -22,17 +25,11 @@
<DialogPrimitive.Content
bind:ref
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] border px-6 shadow-lg duration-200 sm:rounded-lg",
className,
)}
{...restProps}
>
{@render children?.()}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import Check from "@lucide/svelte/icons/check";
import Minus from "@lucide/svelte/icons/minus";
import { cn } from "$lib/utils/cn.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4" />
{:else}
<Check class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { cn } from "$lib/utils/cn.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: DropdownMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...restProps}
/>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils/cn.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils/cn.js";
import { type WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from "bits-ui";
import Circle from "@lucide/svelte/icons/circle";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<Circle class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
class={cn("bg-muted -mx-1 my-1 h-px", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...restProps}
>
{@render children?.()}
</span>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
class={cn(
"bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-lg focus:outline-none",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRight class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>

View file

@ -0,0 +1,50 @@
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
const Trigger = DropdownMenuPrimitive.Trigger;
const Group = DropdownMenuPrimitive.Group;
const RadioGroup = DropdownMenuPrimitive.RadioGroup;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
GroupHeading as DropdownMenuGroupHeading,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};

View file

@ -0,0 +1,15 @@
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
const Root = CollapsiblePrimitive.Root;
const Trigger = CollapsiblePrimitive.Trigger;
const Content = CollapsiblePrimitive.Content;
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};

View file

@ -14,7 +14,10 @@
| { type: "file"; files?: FileList }
| { type?: InputType; files?: undefined }
)
>;
> & {
leftIcon?: typeof import("lucide-svelte").Search;
rightIcon?: typeof import("lucide-svelte").Search;
};
let {
ref = $bindable(null),
@ -22,6 +25,8 @@
type,
files = $bindable(),
class: className,
leftIcon,
rightIcon,
...restProps
}: Props = $props();
</script>
@ -30,7 +35,7 @@
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
type="file"
@ -39,14 +44,29 @@
{...restProps}
/>
{:else}
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{type}
bind:value
{...restProps}
/>
<div class="relative flex w-full">
{#if leftIcon}
<div class="absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground">
<svelte:component this={leftIcon} class="h-4 w-4" />
</div>
{/if}
<input
bind:this={ref}
class={cn(
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
leftIcon && "pl-10",
rightIcon && "pr-10",
leftIcon && rightIcon && "px-10",
className,
)}
{type}
bind:value
{...restProps}
/>
{#if rightIcon}
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground">
<svelte:component this={rightIcon} class="h-4 w-4" />
</div>
{/if}
</div>
{/if}

View file

@ -8,15 +8,15 @@
const { isMobile } = getResponsiveModalContext();
</script>
{#if isMobile.current}
{#if isMobile.current}
<Drawer.Content
class={cn("max-h-[85vh] h-auto p-0 pb-[env(safe-area-inset-bottom)]", className)}
{...props}
>
{@render children?.()}
</Drawer.Content>
{:else}
{:else}
<Dialog.Content class={className} {...props}>
{@render children?.()}
</Dialog.Content>
{/if}
{/if}

View file

@ -9,11 +9,17 @@
</script>
{#if isMobile.current}
<Drawer.Footer class={cn("pt-2", className)} {...props}>
<Drawer.Footer class={cn("pt-2 border-t", className)} {...props}>
{@render children?.()}
</Drawer.Footer>
{:else}
<Dialog.Footer class={cn("p-4 border-t border-border shadow-[0_-1px_3px_rgba(0,0,0,0.05)] relative z-10 bg-background sm:rounded-b-lg", className)} {...props}>
<Dialog.Footer
class={cn(
"py-4 border-t border-border shadow-[0_-1px_3px_rgba(0,0,0,0.05)] relative z-10 bg-background sm:rounded-b-lg",
className,
)}
{...props}
>
{@render children?.()}
</Dialog.Footer>
{/if}

View file

@ -4,25 +4,39 @@
import { getResponsiveModalContext } from "./context";
import { cn } from "$lib/utils/cn";
import { X } from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
let { children, class: className, ...props } = $props();
let { title, class: className, children, ...props } = $props();
const { isMobile } = getResponsiveModalContext();
</script>
{#if isMobile.current}
<Drawer.Header class={cn("text-left", className)} {...props}>
{@render children?.()}
<Drawer.Header class={cn("text-center", className)} {...props}>
{#if title}
<h2 class="text-lg font-semibold">{title}</h2>
{:else}
{@render children?.()}
{/if}
</Drawer.Header>
{:else}
<div class={cn("flex items-center justify-between p-4 border-b border-border shadow-sm relative z-10 bg-background sm:rounded-t-lg", className)}>
<div
class={cn(
"flex items-center justify-between py-4 border-b border-border shadow-sm relative z-10 bg-background sm:rounded-t-lg",
className,
)}
>
<Dialog.Header class="flex-1" {...props}>
{@render children?.()}
{#if title}
<h2 class="text-lg font-semibold">{title}</h2>
{:else}
{@render children?.()}
{/if}
</Dialog.Header>
<Dialog.Close
class="rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="size-5" />
<span class="sr-only">Close</span>
<Dialog.Close>
<Button variant="destructive" size="icon">
<X class="size-6!" />
<span class="sr-only">Close</span>
</Button>
</Dialog.Close>
</div>
{/if}

View file

@ -9,7 +9,7 @@
</script>
{#if isMobile.current}
<Drawer.Title class={cn("text-left", className)} {...props}>
<Drawer.Title class={cn("", className)} {...props}>
{@render children?.()}
</Drawer.Title>
{:else}

View file

@ -24,6 +24,6 @@
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
class={cn("bg-border relative rounded-full", orientation === "vertical" && "flex-1")}
class={cn("bg-border/50 hover:bg-border relative rounded-full transition-colors", orientation === "vertical" && "flex-1")}
/>
</ScrollAreaPrimitive.Scrollbar>

View file

@ -23,10 +23,10 @@
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === "vertical" || orientation === "both"}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
<Scrollbar orientation="vertical" class={cn(scrollbarYClasses, "data-[state=hidden]:animate-none data-[state=hidden]:opacity-100")} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
<Scrollbar orientation="horizontal" class={cn(scrollbarXClasses, "data-[state=hidden]:animate-none data-[state=hidden]:opacity-100")} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View file

@ -13,7 +13,7 @@
bind:ref
class={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
className,
)}
{...restProps}
/>

View file

@ -0,0 +1,10 @@
import Root from "./toggle-group.svelte";
import Item from "./toggle-group-item.svelte";
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem,
};

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { getToggleGroupCtx } from "./toggle-group.svelte";
import { cn } from "$lib/utils/cn.js";
import { type ToggleVariants, toggleVariants } from "$lib/components/ui/toggle/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size,
variant,
...restProps
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
class={cn(
toggleVariants({
variant: ctx.variant || variant,
size: ctx.size || size,
}),
className
)}
{value}
{...restProps}
/>

View file

@ -0,0 +1,41 @@
<script lang="ts" module>
import { getContext, setContext } from "svelte";
import type { ToggleVariants } from "$lib/components/ui/toggle/index.js";
export function setToggleGroupCtx(props: ToggleVariants) {
setContext("toggleGroup", props);
}
export function getToggleGroupCtx() {
return getContext<ToggleVariants>("toggleGroup");
}
</script>
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = "default",
variant = "default",
...restProps
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
variant,
size,
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:value={value as never}
bind:ref
class={cn("flex items-center justify-center gap-1", className)}
{...restProps}
/>

View file

@ -0,0 +1,13 @@
import Root from "./toggle.svelte";
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants,
} from "./toggle.svelte";
export {
Root,
//
Root as Toggle,
};

View file

@ -0,0 +1,51 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const toggleVariants = tv({
base: "ring-offset-background hover:bg-muted hover:text-muted-foreground focus-visible:ring-ring data-[state=on]:bg-accent data-[state=on]:text-accent-foreground inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
outline:
"border-input hover:bg-accent hover:text-accent-foreground border bg-transparent",
},
size: {
default: "h-10 min-w-10 px-3",
sm: "h-9 min-w-9 px-2.5",
lg: "h-11 min-w-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = "default",
variant = "default",
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>

View file

@ -8,7 +8,7 @@
type StreamEvent
} from '$lib/services/ai/interactiveLorebook';
import { OpenAIProvider } from '$lib/services/ai/openrouter';
import { settings, getDefaultInteractiveLorebookSettings } from '$lib/stores/settings.svelte';
import { settings } from '$lib/stores/settings.svelte';
import DiffView from './DiffView.svelte';
import {
X, Send, Loader2, Bot, User, ChevronDown, ChevronUp,
@ -17,6 +17,10 @@
import { fade, slide } from 'svelte/transition';
import { onMount, onDestroy, tick } from 'svelte';
import { parseMarkdown } from '$lib/utils/markdown';
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { Badge } from '$lib/components/ui/badge';
import { cn } from '$lib/utils/cn';
// AbortController for cancelling ongoing requests
let abortController: AbortController | null = null;
@ -368,34 +372,20 @@
</script>
<div
class="fixed inset-0 z-50 flex flex-col bg-surface-800 md:relative md:inset-auto md:z-auto md:w-[400px] md:border-l md:border-surface-700"
class="flex flex-col h-full w-full bg-background border-l-0 md:border-l border-border"
in:slide={{ axis: 'x', duration: 200 }}
>
<!-- Header -->
<div class="flex items-center justify-between px-4 py-4 md:py-3 border-b border-surface-700 bg-surface-800/80 pt-safe">
<div class="flex items-center gap-2">
<Bot class="h-5 w-5 text-purple-400" />
<span class="font-medium text-surface-100">AI Assistant</span>
</div>
<button
class="p-2.5 md:p-1.5 rounded-md text-surface-400 hover:text-surface-200 hover:bg-surface-700 -mr-1 md:mr-0"
onclick={onClose}
title="Close chat"
>
<X class="h-6 w-6 md:h-5 md:w-5" />
</button>
</div>
<!-- Approve All Banner -->
{#if pendingCount >= 2}
<div class="px-4 py-2 bg-purple-500/10 border-b border-purple-500/30" in:slide>
<button
class="w-full flex items-center justify-center gap-2 py-2 rounded-md bg-purple-500/20 text-purple-300 hover:bg-purple-500/30 font-medium text-sm transition-colors"
<div class="px-4 py-2 bg-primary/10 border-b border-primary/20" in:slide>
<Button
variant="ghost"
class="w-full justify-center text-primary hover:text-primary hover:bg-primary/10 gap-2 h-auto py-2"
onclick={handleApproveAll}
>
<CheckCheck class="h-4 w-4" />
Approve All ({pendingCount} changes)
</button>
</Button>
</div>
{/if}
@ -406,34 +396,43 @@
>
{#each messages as message (message.id)}
<div
class="flex {message.role === 'user' ? 'justify-end' : 'justify-start'}"
class={cn(
"flex w-full",
message.role === 'user' ? 'justify-end' : 'justify-start'
)}
in:fade={{ duration: 150 }}
>
<div class="max-w-[90%] md:max-w-[85%] {message.role === 'user' ? 'order-2' : 'order-1'}">
<div class={cn(
"max-w-[90%] md:max-w-[85%]",
message.role === 'user' ? 'order-2' : 'order-1'
)}>
<!-- Message bubble -->
<div
class="rounded-lg p-3 {message.role === 'user'
? 'bg-accent-500/20 text-accent-100'
: 'bg-surface-700 text-surface-100'}"
class={cn(
"rounded-lg p-3 text-sm",
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 border'
)}
>
<!-- Icon -->
<div class="flex items-start gap-2">
{#if message.role === 'assistant'}
<Bot class="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<Bot class="h-4 w-4 mt-0.5 flex-shrink-0 text-primary" />
{:else}
<User class="h-4 w-4 text-accent-400 mt-0.5 flex-shrink-0" />
<User class="h-4 w-4 mt-0.5 flex-shrink-0 opacity-80" />
{/if}
<div class="flex-1 min-w-0">
<div class="text-sm chat-markdown prose-content break-words">{@html parseMarkdown(message.content)}</div>
<div class="chat-markdown prose-content break-words">{@html parseMarkdown(message.content)}</div>
</div>
</div>
<!-- Reasoning (collapsible) -->
{#if message.role === 'assistant' && formatReasoning(message)}
{@const reasoning = formatReasoning(message)}
<div class="mt-2 pt-2 border-t border-surface-600/50">
<div class="mt-2 pt-2 border-t border-border/50">
<button
class="flex items-center gap-1.5 text-xs text-surface-400 hover:text-surface-300"
class="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onclick={() => toggleReasoning(message.id)}
>
<Brain class="h-3 w-3" />
@ -445,7 +444,7 @@
{/if}
</button>
{#if expandedReasoning.has(message.id)}
<div class="mt-2 text-xs text-surface-400 whitespace-pre-wrap" in:slide>
<div class="mt-2 text-xs text-muted-foreground whitespace-pre-wrap font-mono bg-muted/30 p-2 rounded" in:slide>
{reasoning}
</div>
{/if}
@ -457,11 +456,11 @@
{#if message.toolCalls && message.toolCalls.length > 0}
<div class="mt-2 space-y-1">
{#each message.toolCalls as toolCall (toolCall.id)}
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-surface-700/50 text-xs">
<Wrench class="h-3 w-3 text-blue-400 flex-shrink-0" />
<span class="text-blue-300 font-medium">{formatToolCallName(toolCall.name)}</span>
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-muted/30 text-xs border">
<Wrench class="h-3 w-3 text-muted-foreground flex-shrink-0" />
<span class="font-medium text-muted-foreground">{formatToolCallName(toolCall.name)}</span>
{#if toolCall.args && Object.keys(toolCall.args).length > 0}
<span class="text-surface-500">
<span class="text-muted-foreground/70">
{#if toolCall.name === 'list_entries' && toolCall.args.type}
(type: {toolCall.args.type})
{:else if toolCall.name === 'get_entry' && toolCall.args.index !== undefined}
@ -495,9 +494,12 @@
{:else}
<!-- Approved/Rejected indicator -->
<div
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {change.status === 'approved'
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'}"
class={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border",
change.status === 'approved'
? 'bg-green-500/10 text-green-500 border-green-500/20'
: 'bg-destructive/10 text-destructive border-destructive/20'
)}
in:fade
>
{#if change.status === 'approved'}
@ -514,7 +516,10 @@
{/if}
<!-- Timestamp -->
<div class="mt-1 text-xs text-surface-500 {message.role === 'user' ? 'text-right' : ''}">
<div class={cn(
"mt-1 text-xs text-muted-foreground",
message.role === 'user' ? 'text-right' : ''
)}>
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
@ -525,23 +530,23 @@
{#if isGenerating}
<div class="flex justify-start" in:fade>
<div class="max-w-[90%] md:max-w-[85%]">
<div class="bg-surface-700 rounded-lg p-3 text-surface-100">
<div class="bg-muted/50 border rounded-lg p-3">
<div class="flex items-start gap-2">
<Bot class="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<Bot class="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div class="flex-1 min-w-0">
<!-- Active tool calls -->
{#if activeToolCalls.length > 0}
<div class="space-y-1">
{#each activeToolCalls as toolCall (toolCall.id)}
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-surface-600/50 text-xs" in:fade>
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-background text-xs border" in:fade>
{#if toolCall.result === '...'}
<Loader2 class="h-3 w-3 text-blue-400 animate-spin flex-shrink-0" />
<Loader2 class="h-3 w-3 text-primary animate-spin flex-shrink-0" />
{:else}
<Wrench class="h-3 w-3 text-blue-400 flex-shrink-0" />
<Wrench class="h-3 w-3 text-muted-foreground flex-shrink-0" />
{/if}
<span class="text-blue-300 font-medium">{formatToolCallName(toolCall.name)}</span>
<span class="font-medium">{formatToolCallName(toolCall.name)}</span>
{#if toolCall.args && Object.keys(toolCall.args).length > 0}
<span class="text-surface-500">
<span class="text-muted-foreground">
{#if toolCall.name === 'create_entry' && toolCall.args.name}
({toolCall.args.name})
{:else if toolCall.name === 'list_entries' && toolCall.args.type}
@ -555,7 +560,7 @@
{/each}
</div>
{:else if isThinking}
<div class="flex items-center gap-2 text-sm text-surface-400">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>Thinking...</span>
</div>
@ -570,8 +575,8 @@
<!-- Error display -->
{#if error}
<div class="px-4 py-2 bg-red-500/10 border-t border-red-500/30" in:slide>
<div class="flex items-center gap-2 text-sm text-red-400">
<div class="px-4 py-2 bg-destructive/10 border-t border-destructive/20" in:slide>
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
<span>{error}</span>
</div>
@ -579,18 +584,19 @@
{/if}
<!-- Input area -->
<div class="p-4 border-t border-surface-700 bg-surface-800/80 pb-safe">
<div class="flex items-end gap-2 md:gap-2">
<textarea
<div class="p-4 border-t bg-muted/10 pb-safe">
<div class="flex items-end gap-2">
<Textarea
bind:value={inputValue}
onkeydown={handleKeyDown}
placeholder="Describe what you'd like to add..."
rows="2"
class="flex-1 resize-none rounded-lg border border-surface-600 bg-surface-700 px-3 py-3 md:py-2 text-base md:text-sm text-surface-100 placeholder-surface-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500/50"
rows={2}
class="min-h-[2.5rem] resize-none"
disabled={isGenerating || !service}
></textarea>
<button
class="flex items-center justify-center h-12 w-12 md:h-10 md:w-10 rounded-lg bg-purple-600 text-white hover:bg-purple-500 active:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
/>
<Button
size="icon"
class={cn("h-11 w-11 shrink-0", isGenerating && "opacity-80")}
onclick={handleSend}
disabled={!inputValue.trim() || isGenerating || !service}
title="Send message"
@ -600,9 +606,9 @@
{:else}
<Send class="h-5 w-5" />
{/if}
</button>
</Button>
</div>
<div class="mt-2 text-xs text-surface-500 hidden md:block">
<div class="mt-2 text-xs text-muted-foreground hidden md:block text-center">
Press Enter to send, Shift+Enter for new line
</div>
</div>

View file

@ -64,7 +64,7 @@
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[280px] p-0" align="end">
<Popover.Content class="w-70 p-0" align="end">
<!-- Logic Toggle Header -->
<div class="flex items-center justify-between border-b px-3 py-2">
<span class="text-xs font-medium text-muted-foreground"

View file

@ -1,18 +1,22 @@
<script lang="ts">
import type { VaultCharacter, VaultLorebook, VaultScenario } from "$lib/types";
import type {
VaultCharacter,
VaultLorebook,
VaultScenario,
} from "$lib/types";
import { normalizeImageDataUrl } from "$lib/utils/image";
import { Badge } from "$lib/components/ui/badge";
import {
User,
Users,
Book,
MapPin,
MessageSquare,
import {
User,
Users,
Book,
MapPin,
MessageSquare,
Archive,
Box,
Flag,
Brain,
Calendar
Calendar,
} from "lucide-svelte";
import TagBadge from "$lib/components/tags/TagBadge.svelte";
import { tagStore } from "$lib/stores/tags.svelte";
@ -39,13 +43,19 @@
onDelete,
onToggleFavorite,
selectable = false,
onSelect
onSelect,
}: Props = $props();
// Type Guards & Casters
const asCharacter = $derived(type === "character" ? (item as VaultCharacter) : null);
const asLorebook = $derived(type === "lorebook" ? (item as VaultLorebook) : null);
const asScenario = $derived(type === "scenario" ? (item as VaultScenario) : null);
const asCharacter = $derived(
type === "character" ? (item as VaultCharacter) : null,
);
const asLorebook = $derived(
type === "lorebook" ? (item as VaultLorebook) : null,
);
const asScenario = $derived(
type === "scenario" ? (item as VaultScenario) : null,
);
let isImporting = $derived(item.metadata?.importing === true);
@ -88,16 +98,22 @@
class="h-32 w-24 rounded-md object-cover ring-1 ring-border"
/>
{:else}
<div class="flex h-32 w-24 items-center justify-center rounded-md bg-muted">
<div
class="flex h-32 w-24 items-center justify-center rounded-md bg-muted"
>
<User class="h-10 w-10 text-muted-foreground" />
</div>
{/if}
{:else if asLorebook}
<div class="flex h-24 w-24 items-center justify-center rounded-md bg-muted ring-1 ring-border/50">
<div
class="flex h-24 w-24 items-center justify-center rounded-md bg-muted ring-1 ring-border/50"
>
<Book class="h-10 w-10 text-muted-foreground/50" />
</div>
{:else if asScenario}
<div class="flex h-24 w-24 items-center justify-center rounded-md bg-muted ring-1 ring-border/50">
<div
class="flex h-24 w-24 items-center justify-center rounded-md bg-muted ring-1 ring-border/50"
>
<MapPin class="h-10 w-10 text-muted-foreground/50" />
</div>
{/if}
@ -108,9 +124,9 @@
<span class="text-[10px] text-muted-foreground font-medium">
{asLorebook.entries.length} entries
</span>
{#if asLorebook.source === 'story' || asLorebook.source === 'import'}
{#if asLorebook.source === "story" || asLorebook.source === "import"}
<Badge variant="secondary" class="text-[10px] px-1.5 h-4 font-normal">
{asLorebook.source === 'story' ? 'Story' : 'Imported'}
{asLorebook.source === "story" ? "Story" : "Imported"}
</Badge>
{/if}
{:else if asScenario}
@ -138,7 +154,7 @@
{#snippet description()}
{#if item.description}
<p class="text-xs text-muted-foreground line-clamp-3 leading-snug">
<p class="text-xs text-muted-foreground line-clamp-4 leading-snug">
{item.description}
</p>
{/if}
@ -168,7 +184,10 @@
<div class="flex flex-wrap gap-1.5">
{#each lorebookEntryCounts.slice(0, 4) as { type, count }}
{@const Icon = entryTypeIcons[type]}
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/80 bg-muted/50 px-1.5 py-0.5 rounded-sm border border-border/50" title={type}>
<div
class="flex items-center gap-1 text-[10px] text-muted-foreground/80 bg-muted/50 px-1.5 py-0.5 rounded-sm border border-border/50"
title={type}
>
{#if Icon}
<Icon class="h-3 w-3 opacity-70" />
{/if}

View file

@ -124,15 +124,13 @@
}}
>
<ResponsiveModal.Content
class="md:max-w-[600px] flex flex-col md:h-auto md:max-h-[90vh]"
class="md:max-w-150 flex flex-col md:h-auto md:max-h-[90vh]"
>
<ResponsiveModal.Header>
<ResponsiveModal.Title class="text-center sm:text-left w-full"
>{isEditing ? "Edit Character" : "New Character"}</ResponsiveModal.Title
>
</ResponsiveModal.Header>
<ResponsiveModal.Header
title={isEditing ? "Edit Character" : "New Character"}
/>
<div class="flex-1 overflow-y-auto px-4">
<div class="flex-1 overflow-y-auto px-4 sm:pr-4">
<form
id="character-form"
onsubmit={(e) => {
@ -276,7 +274,7 @@
class="w-full"
>
{#if saving}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
<Loader2 class="h-4 w-4 animate-spin" />
{/if}
{isEditing ? "Save Changes" : "Create Character"}
</Button>

View file

@ -1,15 +1,44 @@
<script lang="ts">
import type { VaultLorebook, VaultLorebookEntry, EntryType, EntryInjectionMode } from '$lib/types';
import { lorebookVault } from '$lib/stores/lorebookVault.svelte';
import type {
VaultLorebook,
VaultLorebookEntry,
EntryType,
EntryInjectionMode,
} from "$lib/types";
import { lorebookVault } from "$lib/stores/lorebookVault.svelte";
import {
X, Plus, Search, Trash2, Save, ArrowLeft,
Users, MapPin, Box, Flag, Brain, Calendar,
MoreVertical, AlertCircle, Eye, EyeOff, Bot
} from 'lucide-svelte';
import { fade, slide } from 'svelte/transition';
import InteractiveLorebookChat from './InteractiveLorebookChat.svelte';
import TagInput from '$lib/components/tags/TagInput.svelte';
import * as ResponsiveModal from '$lib/components/ui/responsive-modal';
X,
Plus,
Search,
Trash2,
Save,
ArrowLeft,
Users,
MapPin,
Box,
Flag,
Brain,
Calendar,
MoreVertical,
AlertCircle,
Eye,
EyeOff,
Bot,
BookOpen,
Settings,
List,
} from "lucide-svelte";
import { fade } from "svelte/transition";
import InteractiveLorebookChat from "./InteractiveLorebookChat.svelte";
import TagInput from "$lib/components/tags/TagInput.svelte";
import * as ResponsiveModal from "$lib/components/ui/responsive-modal";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Textarea } from "$lib/components/ui/textarea";
import { Label } from "$lib/components/ui/label";
import * as Tabs from "$lib/components/ui/tabs";
import { cn } from "$lib/utils/cn";
interface Props {
lorebook: VaultLorebook;
@ -20,36 +49,54 @@
// Local state for editing
let name = $state(lorebook.name);
let description = $state(lorebook.description ?? '');
let description = $state(lorebook.description ?? "");
let tags = $state<string[]>([...lorebook.tags]);
let entries = $state<VaultLorebookEntry[]>(JSON.parse(JSON.stringify(lorebook.entries))); // Deep copy
let entries = $state<VaultLorebookEntry[]>(
JSON.parse(JSON.stringify(lorebook.entries)),
); // Deep copy
// UI State
let searchQuery = $state('');
let searchQuery = $state("");
let selectedIndex = $state<number | null>(null);
let showDeleteConfirm = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
let activeTab = $state<'editor' | 'settings'>('editor');
let activeTab = $state("editor");
let showInteractiveChat = $state(false);
// Filtered entries
const filteredEntries = $derived.by(() => {
if (!searchQuery.trim()) return entries.map((e, i) => ({ entry: e, index: i }));
if (!searchQuery.trim())
return entries.map((e, i) => ({ entry: e, index: i }));
const q = searchQuery.toLowerCase();
return entries
.map((e, i) => ({ entry: e, index: i }))
.filter(({ entry }) =>
entry.name.toLowerCase().includes(q) ||
entry.keywords.some(k => k.toLowerCase().includes(q))
.filter(
({ entry }) =>
entry.name.toLowerCase().includes(q) ||
entry.keywords.some((k) => k.toLowerCase().includes(q)),
);
});
const selectedEntry = $derived(selectedIndex !== null ? entries[selectedIndex] : null);
const selectedEntry = $derived(
selectedIndex !== null ? entries[selectedIndex] : null,
);
// Type options
const entryTypes: EntryType[] = ['character', 'location', 'item', 'faction', 'concept', 'event'];
const injectionModes: EntryInjectionMode[] = ['always', 'keyword', 'relevant', 'never'];
const entryTypes: EntryType[] = [
"character",
"location",
"item",
"faction",
"concept",
"event",
];
const injectionModes: EntryInjectionMode[] = [
"always",
"keyword",
"relevant",
"never",
];
const typeIcons: Record<EntryType, any> = {
character: Users,
@ -62,7 +109,7 @@
function handleSave() {
if (!name.trim()) {
error = 'Lorebook name is required';
error = "Lorebook name is required";
return;
}
@ -71,42 +118,56 @@
// Update metadata entry breakdown
const breakdown: Record<EntryType, number> = {
character: 0, location: 0, item: 0, faction: 0, concept: 0, event: 0
character: 0,
location: 0,
item: 0,
faction: 0,
concept: 0,
event: 0,
};
entries.forEach(e => {
entries.forEach((e) => {
if (breakdown[e.type] !== undefined) breakdown[e.type]++;
});
lorebookVault.update(lorebook.id, {
name,
description: description || null,
entries,
tags,
metadata: {
...lorebook.metadata,
format: lorebook.metadata?.format ?? 'aventura',
totalEntries: entries.length,
entryBreakdown: breakdown
}
}).then(() => {
saving = false;
}).catch(e => {
error = e instanceof Error ? e.message : 'Failed to save lorebook';
saving = false;
});
lorebookVault
.update(lorebook.id, {
name,
description: description || null,
entries,
tags,
metadata: {
...lorebook.metadata,
format: lorebook.metadata?.format ?? "aventura",
totalEntries: entries.length,
entryBreakdown: breakdown,
},
})
.then(() => {
saving = false;
onClose(); // Ideally close on save if it's the main save button, or just show success
})
.catch((e) => {
error = e instanceof Error ? e.message : "Failed to save lorebook";
saving = false;
});
}
// Silent save function for auto-saving (doesn't close editor)
async function handleSilentSave(): Promise<void> {
if (!name.trim()) {
throw new Error('Lorebook name is required');
throw new Error("Lorebook name is required");
}
// Update metadata entry breakdown
const breakdown: Record<EntryType, number> = {
character: 0, location: 0, item: 0, faction: 0, concept: 0, event: 0
character: 0,
location: 0,
item: 0,
faction: 0,
concept: 0,
event: 0,
};
entries.forEach(e => {
entries.forEach((e) => {
if (breakdown[e.type] !== undefined) breakdown[e.type]++;
});
@ -117,28 +178,30 @@
tags,
metadata: {
...lorebook.metadata,
format: lorebook.metadata?.format ?? 'aventura',
format: lorebook.metadata?.format ?? "aventura",
totalEntries: entries.length,
entryBreakdown: breakdown
}
entryBreakdown: breakdown,
},
});
}
function handleAddEntry() {
const newEntry: VaultLorebookEntry = {
name: 'New Entry',
type: 'character',
description: '',
name: "New Entry",
type: "character",
description: "",
keywords: [],
injectionMode: 'keyword',
injectionMode: "keyword",
priority: 10,
disabled: false,
group: null
group: null,
};
entries.push(newEntry);
entries = entries; // Trigger update
selectedIndex = entries.length - 1;
activeTab = 'editor';
activeTab = "editor";
// Ensure search doesn't hide the new entry
searchQuery = "";
}
function handleDeleteEntry(index: number) {
@ -161,363 +224,409 @@
}
</script>
<ResponsiveModal.Root open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
<ResponsiveModal.Content class="sm:max-w-6xl w-full sm:h-[90vh] flex flex-col overflow-hidden sm:rounded-lg p-0 bg-surface-900 shadow-xl border border-surface-700">
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 bg-surface-800 px-3 sm:px-6 py-3 sm:py-4 gap-2">
<div class="flex items-center gap-2 sm:gap-4 min-w-0 flex-1">
<div class="flex flex-col min-w-0 flex-1">
<input
type="text"
bind:value={name}
class="bg-transparent text-base sm:text-lg font-bold text-surface-100 placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-accent-500/50 rounded px-2 -ml-2 hover:bg-surface-700/50 transition-colors w-full"
placeholder="Lorebook Name"
/>
<div class="text-xs text-surface-400 px-2 -ml-2">
{entries.length} entries
</div>
<ResponsiveModal.Root
open={true}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<ResponsiveModal.Content
class="sm:max-w-6xl w-full h-[100dvh] sm:h-[90vh] flex flex-col overflow-hidden p-0 rounded-none sm:rounded-lg"
>
<ResponsiveModal.Header class="px-6 py-4 border-b flex-shrink-0 flex items-center justify-center relative">
<ResponsiveModal.Title class="text-center">Edit Lorebook</ResponsiveModal.Title>
{#if error}
<div class="absolute top-full left-0 w-full text-center text-destructive text-sm bg-background/95 backdrop-blur py-1 border-b">
{error}
</div>
{/if}
</ResponsiveModal.Header>
<Tabs.Root
bind:value={activeTab}
class="flex-1 flex flex-col overflow-hidden"
>
<div
class="border-b bg-muted/20 shrink-0 flex items-center justify-between"
>
<Tabs.List class="justify-start h-12 bg-transparent p-0">
<Tabs.Trigger
value="editor"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full px-4"
>
<List class="h-4 w-4 mr-2" />
Entries ({entries.length})
</Tabs.Trigger>
<Tabs.Trigger
value="settings"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full px-4"
>
<Settings class="h-4 w-4 mr-2" />
Settings
</Tabs.Trigger>
</Tabs.List>
<div class="flex items-center gap-2 pr-2">
{#if name.trim()}
<Button
variant={showInteractiveChat ? "secondary" : "ghost"}
size="sm"
class="gap-2"
onclick={() => (showInteractiveChat = !showInteractiveChat)}
>
{#if showInteractiveChat}
<X class="h-4 w-4" />
{:else}
<Bot class="h-4 w-4" />
{/if}
<span class="hidden sm:inline"
>{showInteractiveChat ? "Close Chat" : "AI Assistant"}</span
>
</Button>
{/if}
</div>
</div>
<div class="flex items-center gap-1 sm:gap-3 flex-shrink-0">
{#if error}
<div class="hidden sm:flex items-center gap-2 text-red-400 text-sm mr-4 bg-red-500/10 px-3 py-1.5 rounded-full">
<AlertCircle class="h-4 w-4" />
{error}
</div>
{/if}
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden bg-background relative">
<!-- Tab Contents Wrapper -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<Tabs.Content value="settings" class="flex-1 overflow-y-auto m-0 p-6">
<div class="max-w-2xl mx-auto space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<Label for="name">Lorebook Name</Label>
<Input
id="name"
bind:value={name}
placeholder="Lorebook Name"
/>
</div>
<!-- Settings button - icon only on mobile -->
<button
class="flex items-center justify-center gap-2 rounded-lg bg-surface-700 p-2 sm:px-4 sm:py-2 text-sm font-medium text-surface-200 hover:bg-surface-600 hover:text-white"
onclick={() => activeTab = activeTab === 'settings' ? 'editor' : 'settings'}
title={activeTab === 'settings' ? 'Close Settings' : 'Settings'}
>
<MoreVertical class="h-4 w-4 sm:hidden" />
<span class="hidden sm:inline">{activeTab === 'settings' ? 'Close Settings' : 'Settings'}</span>
</button>
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
id="description"
bind:value={description}
rows={4}
placeholder="Describe what this lorebook contains..."
class="resize-none"
/>
</div>
<!-- AI button - icon only on mobile -->
{#if name.trim()}
<button
class="flex items-center justify-center gap-2 rounded-lg bg-purple-600 p-2 sm:px-4 sm:py-2 text-sm font-medium text-white hover:bg-purple-500"
onclick={() => showInteractiveChat = !showInteractiveChat}
title={showInteractiveChat ? 'Close Chat' : 'Expand with AI'}
>
<Bot class="h-4 w-4" />
<span class="hidden sm:inline">{showInteractiveChat ? 'Close Chat' : 'Expand with AI'}</span>
</button>
{/if}
<div class="space-y-2">
<Label>Tags</Label>
<TagInput
value={tags}
type="lorebook"
onChange={(newTags) => (tags = newTags)}
placeholder="Add tags..."
/>
</div>
<!-- Save button - icon only on mobile -->
<button
class="flex items-center justify-center gap-2 rounded-lg bg-accent-600 p-2 sm:px-4 sm:py-2 text-sm font-medium text-white hover:bg-accent-500 disabled:opacity-50"
onclick={handleSave}
disabled={saving}
title="Save Changes"
>
{#if saving}
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white"></div>
{:else}
<Save class="h-4 w-4" />
{/if}
<span class="hidden sm:inline">Save Changes</span>
</button>
<div class="h-6 w-px bg-surface-700 mx-0.5 sm:mx-1 hidden sm:block"></div>
<button
class="rounded-lg p-2 text-surface-400 hover:bg-surface-700 hover:text-surface-200"
onclick={onClose}
title="Close"
>
<X class="h-5 w-5" />
</button>
</div>
</div>
<!-- Main Content -->
<div class="flex flex-1 overflow-hidden">
{#if activeTab === 'settings'}
<!-- Global Settings Panel -->
<div class="w-full p-4 sm:p-8 overflow-y-auto" in:fade={{ duration: 150 }}>
<div class="max-w-2xl mx-auto space-y-4 sm:space-y-6">
<h3 class="text-lg sm:text-xl font-medium text-surface-100 mb-4 sm:mb-6">Lorebook Settings</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Description</label>
<textarea
bind:value={description}
rows="4"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
placeholder="Describe what this lorebook contains..."
></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">
<div class="flex justify-between p-2 rounded bg-surface-700/50">
<span class="text-surface-400">Total Entries</span>
<span class="text-surface-100">{entries.length}</span>
</div>
<div class="flex justify-between p-2 rounded bg-surface-700/50">
<span class="text-surface-400">Active Entries</span>
<span class="text-surface-100">{entries.filter(e => !e.disabled).length}</span>
<div class="rounded-lg border bg-muted/30 p-4">
<h4 class="text-sm font-medium mb-3">Statistics</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div
class="flex justify-between p-2 rounded bg-background border"
>
<span class="text-muted-foreground">Total Entries</span>
<span>{entries.length}</span>
</div>
<div
class="flex justify-between p-2 rounded bg-background border"
>
<span class="text-muted-foreground">Active Entries</span>
<span>{entries.filter((e) => !e.disabled).length}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Tabs.Content>
{:else}
<!-- Split View: List + Editor -->
<!-- Sidebar (List) - Full width on mobile when no selection, fixed width on desktop -->
<div class="w-full sm:w-80 flex flex-col border-r border-surface-700 bg-surface-800/50 {selectedIndex !== null ? 'hidden sm:flex' : 'flex'}">
<!-- Search -->
<div class="p-3 sm:p-4 border-b border-surface-700 space-y-3">
<div class="relative">
<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 entries..."
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-9 pr-3 py-2.5 sm:py-2 text-base sm:text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
<button
class="w-full flex items-center justify-center gap-2 rounded-lg bg-surface-700 py-3 sm:py-2 text-sm font-medium text-surface-200 hover:bg-surface-600 hover:text-white active:bg-surface-600 transition-colors"
onclick={handleAddEntry}
<Tabs.Content
value="editor"
class="flex-1 flex flex-col sm:flex-row overflow-hidden m-0 h-full"
>
<!-- Sidebar (List) -->
<div
class={cn(
"w-full sm:w-80 flex flex-col sm:border-r sm:bg-muted/10",
selectedIndex !== null && "hidden sm:flex", // Hide on mobile if selected
)}
>
<Plus class="h-4 w-4" />
Add New Entry
</button>
</div>
<div class="p-4 border-b space-y-3">
<div class="relative">
<Search
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
/>
<Input
bind:value={searchQuery}
placeholder="Search entries..."
class="pl-9 bg-background"
/>
</div>
<Button class="w-full" onclick={handleAddEntry}>
<Plus class="h-4 w-4 " /> Add Entry
</Button>
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto p-2 sm:p-2 space-y-1">
{#if filteredEntries.length === 0}
<div class="p-4 text-center text-sm text-surface-500">
{#if searchQuery}
No matches found
<div class="flex-1 overflow-y-auto p-2 space-y-1 flex flex-col">
{#if filteredEntries.length === 0}
<div class="flex-1 flex flex-col items-center justify-center text-center text-sm text-muted-foreground min-h-[200px]">
{#if searchQuery}
No matches found
{:else}
No entries yet
{/if}
</div>
{:else}
No entries yet
{#each filteredEntries as { entry, index }}
{@const Icon = typeIcons[entry.type]}
<button
class={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-3 text-left transition-colors hover:bg-muted/50",
selectedIndex === index &&
"bg-accent text-accent-foreground",
)}
onclick={() => (selectedIndex = index)}
>
<div
class={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-background/50",
selectedIndex === index &&
"bg-background/20 border-transparent",
)}
>
<Icon class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="truncate font-medium text-sm">
{entry.name}
</div>
<div class="flex items-center gap-2 text-xs opacity-70">
<span class="capitalize">{entry.type}</span>
{#if entry.disabled}
<span class="flex items-center gap-0.5 ml-auto">
<EyeOff class="h-3 w-3" />
</span>
{/if}
</div>
</div>
</button>
{/each}
{/if}
</div>
{:else}
{#each filteredEntries as { entry, index }}
{@const Icon = typeIcons[entry.type]}
<button
class="w-full flex items-center gap-3 rounded-lg px-3 py-3 sm:py-2.5 text-left transition-colors active:bg-surface-600 {selectedIndex === index ? 'bg-accent-500/20 text-accent-100 ring-1 ring-accent-500/50' : 'text-surface-300 hover:bg-surface-700 hover:text-surface-100'}"
onclick={() => selectedIndex = index}
>
<div class="flex h-10 w-10 sm:h-8 sm:w-8 items-center justify-center rounded-md bg-surface-800 flex-shrink-0 {selectedIndex === index ? 'bg-accent-500/30' : ''}">
<Icon class="h-5 w-5 sm:h-4 sm:w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="truncate font-medium">{entry.name}</div>
<div class="flex items-center gap-2 text-xs opacity-70">
<span class="capitalize">{entry.type}</span>
{#if entry.disabled}
<span class="flex items-center gap-0.5 text-surface-500">
<EyeOff class="h-3 w-3" /> Disabled
</span>
{/if}
</div>
</div>
</button>
{/each}
{/if}
</div>
</div>
<!-- Editor Area - Hidden on mobile when no selection -->
<div class="flex-1 flex flex-col overflow-hidden bg-surface-900 {selectedIndex === null ? 'hidden sm:flex' : 'flex'}">
{#if selectedEntry !== null && selectedIndex !== null}
<!-- Entry Editor Header -->
<div class="flex items-center justify-between border-b border-surface-700 px-3 sm:px-6 py-3 sm:py-4 bg-surface-800/30">
<div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<!-- Back button on mobile -->
<button
class="sm:hidden p-2 -ml-1 text-surface-400 hover:text-surface-100 rounded-lg hover:bg-surface-700"
onclick={() => selectedIndex = null}
title="Back to list"
>
<ArrowLeft class="h-5 w-5" />
</button>
<input
type="text"
bind:value={selectedEntry.name}
class="bg-transparent text-lg sm:text-xl font-bold text-surface-100 placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-accent-500/50 rounded px-2 -ml-2 sm:ml-0 min-w-0 flex-1"
placeholder="Entry Name"
/>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
class="p-2 text-surface-400 hover:text-surface-100 rounded-lg hover:bg-surface-700"
onclick={() => handleDuplicateEntry(selectedIndex!)}
title="Duplicate Entry"
>
<span class="text-xs font-medium hidden sm:inline">Duplicate</span>
<Plus class="h-4 w-4 sm:hidden" />
</button>
<button
class="p-2 text-red-400 hover:text-red-300 rounded-lg hover:bg-red-500/10"
onclick={() => handleDeleteEntry(selectedIndex!)}
title="Delete Entry"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<!-- Entry Editor Form -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6">
<div class="max-w-3xl mx-auto space-y-4 sm:space-y-6">
<!-- Basic Info Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- Type -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Entry Type</label>
<div class="relative">
<select
bind:value={selectedEntry.type}
class="w-full appearance-none rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 focus:border-accent-500 focus:outline-none"
>
{#each entryTypes as type}
<option value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
{/each}
</select>
<div class="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-surface-500">
<MoreVertical class="h-4 w-4" />
</div>
</div>
</div>
<!-- Group -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Group (Optional)</label>
<input
type="text"
bind:value={selectedEntry.group}
placeholder="e.g. Main Cast, Kingdom A"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
<!-- Editor Area -->
<div
class={cn(
"flex-1 flex flex-col overflow-hidden bg-background",
selectedIndex === null && "hidden sm:flex",
)}
>
{#if selectedEntry !== null && selectedIndex !== null}
<div
class="flex items-center justify-between border-b px-6 py-4 flex-shrink-0"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<Button
variant="ghost"
size="icon"
class="sm:hidden -ml-2"
onclick={() => (selectedIndex = null)}
>
<ArrowLeft class="h-5 w-5" />
</Button>
<Input
bind:value={selectedEntry.name}
class="text-lg font-semibold h-auto px-2 py-1 border-transparent hover:border-input focus:border-input transition-colors w-full sm:w-auto min-w-[200px]"
/>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onclick={() => handleDuplicateEntry(selectedIndex!)}
>
<span class="hidden sm:inline">Duplicate</span>
<Plus class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => handleDeleteEntry(selectedIndex!)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Keywords -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Keywords</label>
<input
type="text"
value={selectedEntry.keywords.join(', ')}
oninput={(e) => selectedEntry!.keywords = e.currentTarget.value.split(',').map(k => k.trim()).filter(Boolean)}
placeholder="Comma-separated keywords for activation"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
<p class="mt-1.5 text-xs text-surface-500">
Terms that trigger this entry when using 'Keyword' injection mode.
</p>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-3xl mx-auto space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<Label>Entry Type</Label>
<select
bind:value={selectedEntry.type}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each entryTypes as type}
<option value={type}
>{type.charAt(0).toUpperCase() +
type.slice(1)}</option
>
{/each}
</select>
</div>
<!-- Description -->
<div class="flex-1 flex flex-col min-h-[200px]">
<label class="block text-sm font-medium text-surface-300 mb-1">Description / Content</label>
<textarea
bind:value={selectedEntry.description}
class="flex-1 w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none font-mono text-sm leading-relaxed"
placeholder="Enter the lore content here..."
></textarea>
</div>
<!-- Advanced Settings -->
<div class="rounded-lg border border-surface-700 bg-surface-800/50 p-4 space-y-4">
<h4 class="text-sm font-medium text-surface-200">Injection Settings</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Mode -->
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Injection Mode</label>
<select
bind:value={selectedEntry.injectionMode}
class="w-full rounded bg-surface-700 border-transparent px-2 py-1.5 text-sm text-surface-100 focus:ring-1 focus:ring-accent-500"
>
{#each injectionModes as mode}
<option value={mode}>{mode.charAt(0).toUpperCase() + mode.slice(1)}</option>
{/each}
</select>
<div class="space-y-2">
<Label>Group (Optional)</Label>
<Input
bind:value={selectedEntry.group}
placeholder="e.g. Main Cast, Kingdom A"
/>
</div>
</div>
<!-- Priority -->
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Priority</label>
<input
type="number"
bind:value={selectedEntry.priority}
class="w-full rounded bg-surface-700 border-transparent px-2 py-1.5 text-sm text-surface-100 focus:ring-1 focus:ring-accent-500"
<div class="space-y-2">
<Label>Keywords</Label>
<Input
value={selectedEntry.keywords.join(", ")}
oninput={(e) =>
(selectedEntry!.keywords = e.currentTarget.value
.split(",")
.map((k) => k.trim())
.filter(Boolean))}
placeholder="Comma-separated keywords..."
/>
<p class="text-[0.8rem] text-muted-foreground">
Terms that trigger this entry when using 'Keyword'
injection mode.
</p>
</div>
<div class="space-y-2 flex-1 flex flex-col">
<Label>Description / Content</Label>
<Textarea
bind:value={selectedEntry.description}
class="font-mono text-sm leading-relaxed min-h-[200px]"
placeholder="Enter the lore content here..."
/>
</div>
<!-- Enabled Toggle -->
<div class="flex items-end pb-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!selectedEntry.disabled}
onchange={() => selectedEntry!.disabled = !selectedEntry!.disabled}
class="h-4 w-4 rounded border-surface-600 bg-surface-700 text-accent-500 focus:ring-offset-surface-800"
/>
<span class="text-sm text-surface-200">
{selectedEntry.disabled ? 'Disabled' : 'Enabled'}
</span>
</label>
<div class="rounded-lg border bg-muted/30 p-4 space-y-4">
<h4 class="text-sm font-medium">Injection Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="space-y-2">
<Label class="text-xs">Injection Mode</Label>
<select
bind:value={selectedEntry.injectionMode}
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#each injectionModes as mode}
<option value={mode}
>{mode.charAt(0).toUpperCase() +
mode.slice(1)}</option
>
{/each}
</select>
</div>
<div class="space-y-2">
<Label class="text-xs">Priority</Label>
<Input
type="number"
bind:value={selectedEntry.priority}
class="h-9"
/>
</div>
<div class="flex items-end pb-1">
<label
class="flex items-center gap-2 cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<input
type="checkbox"
checked={!selectedEntry.disabled}
onchange={() =>
(selectedEntry!.disabled =
!selectedEntry!.disabled)}
class="h-4 w-4 rounded border-primary text-primary shadow focus:ring-1 focus:ring-ring"
/>
<span>Enabled</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
{:else}
<div
class="flex-1 flex flex-col items-center justify-center text-muted-foreground"
>
<div class="bg-muted/30 p-6 rounded-full mb-4">
<Search class="h-8 w-8 opacity-50" />
</div>
<p class="text-lg font-medium">Select an entry to edit</p>
<p class="text-sm mt-2">Or click "Add Entry" to create one</p>
</div>
{/if}
</div>
{:else}
<!-- Empty State -->
<div class="flex-1 flex flex-col items-center justify-center text-surface-500">
<div class="bg-surface-800/50 p-6 rounded-full mb-4">
<Search class="h-8 w-8 opacity-50" />
</div>
<p class="text-lg font-medium text-surface-400">Select an entry to edit</p>
<p class="text-sm text-surface-600 mt-2">Or click "Add New Entry" to create one</p>
</div>
{/if}
</Tabs.Content>
</div>
{/if}
<!-- Interactive Chat Panel -->
{#if showInteractiveChat && name.trim()}
<InteractiveLorebookChat
{lorebook}
{entries}
onEntriesChange={(newEntries) => { entries = newEntries; }}
onClose={() => showInteractiveChat = false}
onSave={handleSilentSave}
/>
{/if}
</div>
<!-- Interactive Chat Sidebar -->
{#if showInteractiveChat && name.trim()}
<div
class="absolute inset-0 z-50 bg-background md:static md:w-[400px] md:border-l md:border-border flex flex-col"
>
<InteractiveLorebookChat
{lorebook}
{entries}
onEntriesChange={(newEntries) => {
entries = newEntries;
}}
onClose={() => (showInteractiveChat = false)}
onSave={handleSilentSave}
/>
</div>
{/if}
</div>
</Tabs.Root>
<ResponsiveModal.Footer
class="border-t bg-muted/40 px-6 py-4 flex-shrink-0"
>
<div class="flex w-full items-center gap-2 sm:justify-end">
<Button
variant="outline"
class="w-10 p-0 sm:w-auto sm:px-4"
onclick={onClose}
disabled={saving}
>
<X class="h-4 w-4" />
<span class="hidden sm:inline">Cancel</span>
</Button>
<Button
class="flex-1 sm:flex-none"
onclick={handleSave}
disabled={saving || !name.trim()}
>
{#if saving}
<div
class=" h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
{:else}
<Save class=" h-4 w-4" />
{/if}
Save Changes
</Button>
</div>
</ResponsiveModal.Footer>
</ResponsiveModal.Content>
</ResponsiveModal.Root>

View file

@ -59,9 +59,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);
let selectedTags = $state<string[]>([]);
let filterLogic = $state<"AND" | "OR">("OR");
let showTagManager = $state(false);
// Modal States
let showCharForm = $state(false);
@ -433,17 +433,13 @@
{/each}
<div class="flex items-center gap-2">
<div class="relative flex-1">
<SearchIcon
class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"
/>
<Input
type="text"
bind:value={searchQuery}
placeholder={`Search ${activeTab}...`}
class="pl-9 bg-muted/40"
/>
</div>
<Input
type="text"
bind:value={searchQuery}
placeholder={`Search ${activeTab}...`}
class="flex-1 bg-muted/40"
leftIcon={SearchIcon}
/>
<div class="flex items-center gap-2 shrink-0">
<TagFilter
@ -595,5 +591,8 @@
<!-- Tag Manager Modal -->
{#if showTagManager}
<TagManager onClose={() => (showTagManager = false)} />
<TagManager
open={showTagManager}
onOpenChange={(v) => (showTagManager = v)}
/>
{/if}

View file

@ -1,19 +1,39 @@
<script lang="ts">
import type { VaultScenario, VaultScenarioNpc, VaultCharacter } from '$lib/types';
import { scenarioVault } from '$lib/stores/scenarioVault.svelte';
import { characterVault } from '$lib/stores/characterVault.svelte';
import {
X, Save, AlertCircle, Plus, Trash2, Users, MessageSquare,
FileText, MapPin, User, ChevronDown, ChevronRight, Search
} from 'lucide-svelte';
import { fade, slide } from 'svelte/transition';
import TagInput from '$lib/components/tags/TagInput.svelte';
import VaultListItem from './shared/VaultListItem.svelte';
import type {
VaultScenario,
VaultScenarioNpc,
VaultCharacter,
} from "$lib/types";
import { scenarioVault } from "$lib/stores/scenarioVault.svelte";
import { characterVault } from "$lib/stores/characterVault.svelte";
import {
X,
Save,
Plus,
Trash2,
Users,
MessageSquare,
FileText,
MapPin,
ChevronDown,
ChevronRight,
Search,
Loader2,
User,
} from "lucide-svelte";
import TagInput from "$lib/components/tags/TagInput.svelte";
import VaultListItem from "./shared/VaultListItem.svelte";
import { normalizeImageDataUrl } from "$lib/utils/image";
import * as Avatar from "$lib/components/ui/avatar";
import { cn } from "$lib/utils/cn";
import * as ResponsiveModal from '$lib/components/ui/responsive-modal';
import { Button } from '$lib/components/ui/button';
import * as ResponsiveModal from "$lib/components/ui/responsive-modal";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Textarea } from "$lib/components/ui/textarea";
import { Label } from "$lib/components/ui/label";
import * as Tabs from "$lib/components/ui/tabs";
import * as Avatar from "$lib/components/ui/avatar";
import * as Collapsible from "$lib/components/ui/collapsible";
interface Props {
scenario: VaultScenario;
@ -22,530 +42,484 @@
let { scenario, onClose }: Props = $props();
// Local state for editing
// Form State
let name = $state(scenario.name);
let description = $state(scenario.description || '');
let description = $state(scenario.description || "");
let settingSeed = $state(scenario.settingSeed);
let npcs = $state<VaultScenarioNpc[]>(JSON.parse(JSON.stringify(scenario.npcs))); // Deep copy
let firstMessage = $state(scenario.firstMessage || '');
let npcs = $state<VaultScenarioNpc[]>(
JSON.parse(JSON.stringify(scenario.npcs)),
);
let firstMessage = $state(scenario.firstMessage || "");
let alternateGreetings = $state<string[]>([...scenario.alternateGreetings]);
let tags = $state<string[]>([...scenario.tags]);
// UI State
let activeTab = $state<'general' | 'npcs' | 'opening'>('general');
let saving = $state(false);
let error = $state<string | null>(null);
// Character Selector State
let showCharacterSelector = $state(false);
let charSearchQuery = $state('');
// Collapsed state tracking (Set of indices that are expanded)
let expandedNpcs = $state<Set<number>>(new Set());
// Derived filtered characters
let showCharacterSelector = $state(false);
let charSearchQuery = $state("");
// Tab state
let activeTab = $state("general");
const filteredCharacters = $derived.by(() => {
if (!showCharacterSelector) return [];
let chars = characterVault.characters;
if (charSearchQuery.trim()) {
const q = charSearchQuery.toLowerCase();
chars = chars.filter(c => c.name.toLowerCase().includes(q));
chars = chars.filter((c) => c.name.toLowerCase().includes(q));
}
return chars;
});
function toggleNpc(index: number) {
const newSet = new Set(expandedNpcs);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
expandedNpcs = newSet;
}
function handleSave() {
async function handleSave() {
if (!name.trim()) {
error = 'Scenario name is required';
error = "Scenario name is required";
return;
}
saving = true;
error = null;
scenarioVault.update(scenario.id, {
name,
description: description || null,
settingSeed,
npcs,
firstMessage: firstMessage || null,
alternateGreetings,
tags,
metadata: {
...scenario.metadata,
npcCount: npcs.length,
hasFirstMessage: !!firstMessage,
alternateGreetingsCount: alternateGreetings.length
}
}).then(() => {
try {
await scenarioVault.update(scenario.id, {
name,
description: description || null,
settingSeed,
npcs,
firstMessage: firstMessage || null,
alternateGreetings,
tags,
metadata: {
...scenario.metadata,
npcCount: npcs.length,
hasFirstMessage: !!firstMessage,
alternateGreetingsCount: alternateGreetings.length,
},
});
onClose();
}).catch(e => {
error = e instanceof Error ? e.message : 'Failed to save scenario';
} catch (e) {
error = e instanceof Error ? e.message : "Failed to save scenario";
} finally {
saving = false;
});
}
}
// NPC Management
function addNpc() {
const newIndex = npcs.length;
npcs.push({
name: 'New NPC',
role: 'Supporting Character',
description: '',
relationship: 'Neutral',
traits: []
name: "New NPC",
role: "Supporting Character",
description: "",
relationship: "Neutral",
traits: [],
});
npcs = npcs; // Trigger update
// Auto-expand the new NPC
const newSet = new Set(expandedNpcs);
newSet.add(newIndex);
expandedNpcs = newSet;
npcs = npcs; // Trigger reactivity
}
function removeNpc(index: number) {
npcs.splice(index, 1);
npcs = npcs; // Trigger update
// Adjust expanded indices
const newSet = new Set<number>();
for (const i of expandedNpcs) {
if (i < index) newSet.add(i);
else if (i > index) newSet.add(i - 1);
}
expandedNpcs = newSet;
npcs = npcs.filter((_, i) => i !== index);
}
function addNpcFromCharacter(char: VaultCharacter) {
const newIndex = npcs.length;
npcs.push({
name: char.name,
role: 'Supporting Character',
description: char.description || '',
relationship: 'Neutral',
traits: [...char.traits]
role: "Supporting Character",
description: char.description || "",
relationship: "Neutral",
traits: [...char.traits],
});
npcs = npcs;
// Auto-expand
const newSet = new Set(expandedNpcs);
newSet.add(newIndex);
expandedNpcs = newSet;
showCharacterSelector = false;
}
// Alternate Greetings Management
function addGreeting() {
alternateGreetings.push('');
alternateGreetings = alternateGreetings;
alternateGreetings = [...alternateGreetings, ""];
}
function removeGreeting(index: number) {
alternateGreetings.splice(index, 1);
alternateGreetings = alternateGreetings;
alternateGreetings = alternateGreetings.filter((_, i) => i !== index);
}
</script>
<ResponsiveModal.Root open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
<ResponsiveModal.Content class="sm:max-w-4xl w-full sm:h-[90vh] flex flex-col overflow-hidden p-0">
<!-- Header -->
<ResponsiveModal.Header class="bg-surface-800">
<ResponsiveModal.Root
open={true}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<ResponsiveModal.Content
class="md:max-w-4xl flex flex-col h-[95vh] md:h-[85vh] p-0 overflow-hidden"
>
<ResponsiveModal.Header class="px-6 py-4 border-b bg-muted/40">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-700">
<MapPin class="h-5 w-5 text-green-400" />
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"
>
<MapPin class="h-5 w-5 text-primary" />
</div>
<div>
<h2 class="text-lg font-semibold">Edit Scenario</h2>
<p class="text-sm text-muted-foreground">
Modify your scenario settings and characters.
</p>
</div>
<ResponsiveModal.Title class="text-lg font-semibold text-surface-100">
Edit Scenario
</ResponsiveModal.Title>
</div>
</ResponsiveModal.Header>
<!-- Tabs -->
<div class="flex border-b border-surface-700 bg-surface-800/50 px-6">
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'general' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => activeTab = 'general'}
>
<FileText class="h-4 w-4" />
General
</button>
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'npcs' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => activeTab = 'npcs'}
>
<Users class="h-4 w-4" />
NPCs ({npcs.length})
</button>
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'opening' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => activeTab = 'opening'}
>
<MessageSquare class="h-4 w-4" />
Opening
</button>
</div>
<Tabs.Root
value={activeTab}
onValueChange={(v) => (activeTab = v)}
class="flex-1 flex flex-col overflow-hidden"
>
<div class="border-b bg-muted/20">
<Tabs.List class="w-full justify-start h-12 bg-transparent p-0">
<Tabs.Trigger
value="general"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full px-4"
>
<FileText class="h-4 w-4 mr-2" />
General
</Tabs.Trigger>
<Tabs.Trigger
value="npcs"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full px-4"
>
<Users class="h-4 w-4 mr-2" />
NPCs ({npcs.length})
</Tabs.Trigger>
<Tabs.Trigger
value="opening"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full px-4"
>
<MessageSquare class="h-4 w-4 mr-2" />
Opening
</Tabs.Trigger>
</Tabs.List>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6 bg-surface-900">
<div class="max-w-3xl mx-auto">
<!-- General Tab -->
{#if activeTab === 'general'}
<div class="space-y-6" in:fade={{ duration: 150 }}>
<div class="grid grid-cols-1 gap-6">
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Scenario Name</label>
<input
type="text"
<div class="flex-1 overflow-y-auto bg-background">
<div class="px-6 py-2 max-w-3xl mx-auto space-y-6">
{#if error}
<div
class="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive flex items-center gap-2"
>
<Loader2 class="h-4 w-4" />
{error}
</div>
{/if}
<Tabs.Content value="general" class="mt-0 space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<Label for="name">Scenario Name</Label>
<Input
id="name"
bind:value={name}
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
placeholder="e.g. The Cyberpunk City"
/>
</div>
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Description (Short Summary)</label>
<textarea
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
id="description"
bind:value={description}
rows="2"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
rows={2}
placeholder="Brief overview shown on the card..."
></textarea>
class="resize-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Setting Seed (World Details)</label>
<p class="text-xs text-surface-500 mb-2">The core setting information used to generate the story world.</p>
<textarea
bind:value={settingSeed}
rows="12"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none font-mono leading-relaxed"
placeholder="Detailed world setting..."
></textarea>
<div class="space-y-2">
<Label for="seed">Setting Seed</Label>
<div class="relative">
<Textarea
id="seed"
bind:value={settingSeed}
rows={10}
class="font-mono text-sm leading-relaxed resize-y min-h-[200px]"
placeholder="Detailed world setting..."
/>
<div
class="absolute bottom-2 right-2 text-xs text-muted-foreground bg-background/80 px-2 py-0.5 rounded"
>
Markdown supported
</div>
</div>
<p class="text-[0.8rem] text-muted-foreground">
The core world details used for generation.
</p>
</div>
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Tags</label>
<div class="space-y-2">
<Label>Tags</Label>
<TagInput
value={tags}
type="scenario"
onChange={(newTags) => tags = newTags}
onChange={(t) => (tags = t)}
placeholder="Add tags..."
/>
</div>
</div>
</div>
{/if}
</Tabs.Content>
<!-- NPCs Tab -->
{#if activeTab === 'npcs'}
<div class="space-y-6" in:fade={{ duration: 150 }}>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-surface-300">Supporting Characters</h3>
<Tabs.Content value="npcs" class="mt-0 space-y-6">
<div
class="flex items-center justify-between sticky top-0 bg-background z-10 pt-2 pb-4 border-b mb-4"
>
<h3 class="text-sm font-medium text-muted-foreground">
Supporting Characters
</h3>
<div class="flex items-center gap-2">
<button
onclick={() => { charSearchQuery = ''; showCharacterSelector = true; }}
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-xs font-medium text-surface-200 hover:border-surface-500 hover:bg-surface-600 transition-colors"
<Button
variant="outline"
size="sm"
onclick={() => {
charSearchQuery = "";
showCharacterSelector = true;
}}
>
<Users class="h-3.5 w-3.5" />
Add from Vault
</button>
<button
onclick={addNpc}
class="flex items-center gap-2 rounded-lg bg-surface-700 px-3 py-1.5 text-xs font-medium text-surface-200 hover:bg-surface-600 hover:text-white transition-colors"
>
<Plus class="h-3.5 w-3.5" />
Add NPC
</button>
<Users class="h-3.5 w-3.5 " />
Import
</Button>
<Button size="sm" onclick={addNpc}>
<Plus class="h-3.5 w-3.5 " />
New NPC
</Button>
</div>
</div>
<div class="space-y-4">
<div class="space-y-3">
{#if npcs.length === 0}
<div class="rounded-lg border border-dashed border-surface-700 p-8 text-center text-surface-500">
<Users class="mx-auto h-8 w-8 opacity-50 mb-2" />
<div
class="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg text-muted-foreground bg-muted/30"
>
<Users class="h-10 w-10 opacity-20 mb-2" />
<p>No NPCs defined yet.</p>
<Button variant="link" onclick={addNpc}
>Create your first NPC</Button
>
</div>
{:else}
{#each npcs as npc, i}
<div class="rounded-lg border border-surface-700 bg-surface-800/50 overflow-hidden">
<!-- Header / Collapsed View -->
<div
class="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface-700/50 transition-colors"
onclick={() => toggleNpc(i)}
onkeydown={(e) => e.key === 'Enter' && toggleNpc(i)}
role="button"
tabindex="0"
>
<div class="flex items-center gap-3">
{#if expandedNpcs.has(i)}
<ChevronDown class="h-4 w-4 text-surface-400" />
{:else}
<ChevronRight class="h-4 w-4 text-surface-400" />
{/if}
<div>
<div class="font-medium text-surface-200 text-sm">
{npc.name || 'Unnamed NPC'}
<div
class="rounded-lg border bg-card text-card-foreground shadow-sm group"
>
<Collapsible.Root>
<div class="flex items-center p-3 pl-4 gap-3">
<Collapsible.Trigger
class="flex items-center gap-2 flex-1 text-left group/trigger"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/50 transition-colors group-hover/trigger:bg-muted"
>
<ChevronRight
class="h-4 w-4 transition-transform duration-200 group-data-[state=open]/trigger:rotate-90"
/>
</div>
<div class="text-xs text-surface-400">
{npc.role || 'No role'}
<div class="flex-1">
<div class="font-medium text-sm">
{npc.name || "Unnamed NPC"}
</div>
<div class="text-xs text-muted-foreground">
{npc.role || "No role"}
</div>
</div>
</div>
</Collapsible.Trigger>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive"
onclick={() => removeNpc(i)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
<button
onclick={(e) => { e.stopPropagation(); removeNpc(i); }}
class="p-1.5 text-surface-500 hover:bg-red-500/10 hover:text-red-400 rounded transition-colors"
title="Remove NPC"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
<!-- Expanded Content -->
{#if expandedNpcs.has(i)}
<div class="p-4 border-t border-surface-700 bg-surface-800/30" transition:slide={{ duration: 200 }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Name</label>
<input
type="text"
bind:value={npc.name}
class="w-full rounded bg-surface-700/50 border border-surface-600 px-3 py-1.5 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
<Collapsible.Content>
<div
class="px-4 pb-4 pt-0 space-y-4 border-t bg-muted/10 mt-2 p-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label class="text-xs">Name</Label>
<Input bind:value={npc.name} class="h-8" />
</div>
<div class="space-y-2">
<Label class="text-xs">Role</Label>
<Input bind:value={npc.role} class="h-8" />
</div>
</div>
<div class="space-y-2">
<Label class="text-xs">Description</Label>
<Textarea
bind:value={npc.description}
rows={2}
class="min-h-[60px] resize-none"
/>
</div>
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Role</label>
<input
type="text"
bind:value={npc.role}
class="w-full rounded bg-surface-700/50 border border-surface-600 px-3 py-1.5 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label class="text-xs">Relationship</Label>
<Input
bind:value={npc.relationship}
class="h-8"
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Traits</Label>
<Input
value={npc.traits.join(", ")}
oninput={(e) =>
(npc.traits = e.currentTarget.value
.split(",")
.map((t) => t.trim())
.filter(Boolean))}
class="h-8"
placeholder="Comma separated..."
/>
</div>
</div>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-surface-400 mb-1">Description</label>
<textarea
bind:value={npc.description}
rows="2"
class="w-full rounded bg-surface-700/50 border border-surface-600 px-3 py-1.5 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Relationship</label>
<input
type="text"
bind:value={npc.relationship}
class="w-full rounded bg-surface-700/50 border border-surface-600 px-3 py-1.5 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
/>
</div>
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Traits (comma separated)</label>
<input
type="text"
value={npc.traits.join(', ')}
oninput={(e) => npc.traits = e.currentTarget.value.split(',').map(t => t.trim()).filter(Boolean)}
class="w-full rounded bg-surface-700/50 border border-surface-600 px-3 py-1.5 text-sm text-surface-100 focus:border-accent-500 focus:outline-none"
/>
</div>
</div>
</div>
{/if}
</Collapsible.Content>
</Collapsible.Root>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</Tabs.Content>
<!-- Opening Tab -->
{#if activeTab === 'opening'}
<div class="space-y-8" in:fade={{ duration: 150 }}>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-surface-300">First Message (Opening Scene)</label>
<span class="text-xs text-surface-500">The initial message displayed when starting the story.</span>
</div>
<textarea
<Tabs.Content value="opening" class="mt-0 space-y-6">
<div class="space-y-2">
<Label>First Message</Label>
<Textarea
bind:value={firstMessage}
rows="8"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none font-mono leading-relaxed"
placeholder="Write the opening scene..."
></textarea>
rows={6}
class="font-mono text-sm leading-relaxed"
placeholder="The opening scene..."
/>
<p class="text-[0.8rem] text-muted-foreground">
Shown when the story begins.
</p>
</div>
<div>
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-surface-300">Alternate Greetings</h3>
<p class="text-xs text-surface-500">Variations of the opening scene.</p>
<div class="space-y-4">
<div class="flex items-center justify-between">
<Label>Alternate Greetings</Label>
<Button variant="outline" size="sm" onclick={addGreeting}>
<Plus class="h-3.5 w-3.5 " /> Add
</Button>
</div>
{#each alternateGreetings as greeting, i}
<div class="relative">
<Textarea
bind:value={alternateGreetings[i]}
rows={3}
class="pr-10 font-mono text-sm"
placeholder={`Variation ${i + 1}...`}
/>
<Button
variant="ghost"
size="icon"
class="absolute top-2 right-2 h-6 w-6 text-muted-foreground hover:text-destructive"
onclick={() => removeGreeting(i)}
>
<Trash2 class="h-3 w-3" />
</Button>
</div>
<button
onclick={addGreeting}
class="flex items-center gap-2 rounded-lg bg-surface-700 px-3 py-1.5 text-xs font-medium text-surface-200 hover:bg-surface-600 hover:text-white transition-colors"
{/each}
{#if alternateGreetings.length === 0}
<p
class="text-sm text-muted-foreground italic text-center py-4"
>
<Plus class="h-3.5 w-3.5" />
Add Variation
</button>
</div>
<div class="space-y-4">
{#each alternateGreetings as greeting, i}
<div class="relative group">
<textarea
bind:value={alternateGreetings[i]}
rows="4"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 pr-10 text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none font-mono"
placeholder={`Variation ${i + 1}...`}
></textarea>
<button
onclick={() => removeGreeting(i)}
class="absolute top-2 right-2 p-1.5 text-surface-500 hover:bg-red-500/10 hover:text-red-400 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Remove variation"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
{/each}
{#if alternateGreetings.length === 0}
<p class="text-sm text-surface-500 italic">No alternate greetings defined.</p>
{/if}
</div>
No variations added.
</p>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Actions -->
<ResponsiveModal.Footer class="bg-surface-800">
<div class="flex w-full items-center justify-between gap-4">
{#if error}
<div class="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-1.5 rounded-full">
<AlertCircle class="h-4 w-4" />
{error}
</div>
{:else}
<div class="hidden sm:block"></div>
{/if}
<div class="flex items-center gap-3 w-full sm:w-auto">
<Button
variant="ghost"
onclick={onClose}
disabled={saving}
class="flex-1 sm:flex-initial"
>
Cancel
</Button>
<Button
onclick={handleSave}
disabled={saving}
class="flex-1 sm:flex-initial"
>
{#if saving}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Save class="mr-2 h-4 w-4" />
{/if}
Save Changes
</Button>
</Tabs.Content>
</div>
</div>
</Tabs.Root>
<ResponsiveModal.Footer class="border-t bg-muted/40 px-6 py-4">
<div class="flex w-full items-center justify-end gap-2">
<Button variant="outline" onclick={onClose} disabled={saving}>
Cancel
</Button>
<Button onclick={handleSave} disabled={saving || !name.trim()}>
{#if saving}
<Loader2 class=" h-4 w-4 animate-spin" />
{:else}
<Save class=" h-4 w-4" />
{/if}
Save Changes
</Button>
</div>
</ResponsiveModal.Footer>
</ResponsiveModal.Content>
</ResponsiveModal.Root>
<!-- Character Selector Modal (Nested) -->
{#if showCharacterSelector}
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onclick={(e) => { e.stopPropagation(); showCharacterSelector = false; }}
role="dialog"
aria-modal="true"
>
<div
class="flex h-[70vh] w-full max-w-lg flex-col rounded-xl bg-surface-900 shadow-2xl ring-1 ring-surface-700"
onclick={(e) => e.stopPropagation()}
role="document"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 bg-surface-800 px-4 py-3">
<h3 class="font-semibold text-surface-100">Select Character</h3>
<button
onclick={() => showCharacterSelector = false}
class="rounded-lg p-1.5 text-surface-400 hover:bg-surface-700 hover:text-surface-200"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Character Import Modal -->
{#if showCharacterSelector}
<ResponsiveModal.Root
open={showCharacterSelector}
onOpenChange={(open) => (showCharacterSelector = open)}
>
<ResponsiveModal.Content class="sm:max-w-md h-[500px] flex flex-col p-0">
<ResponsiveModal.Header class="px-4 py-3 border-b">
<h3 class="font-semibold">Import Character</h3>
</ResponsiveModal.Header>
<!-- Search -->
<div class="p-4 border-b border-surface-700">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
<input
type="text"
bind:value={charSearchQuery}
placeholder="Search vault characters..."
class="w-full rounded-lg border border-surface-600 bg-surface-800 pl-9 pr-3 py-2 text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
autoFocus
/>
</div>
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if filteredCharacters.length === 0}
<div class="p-8 text-center text-surface-500">
<Users class="mx-auto h-8 w-8 opacity-50 mb-2" />
<p>No matching characters found.</p>
</div>
{:else}
{#each filteredCharacters as char (char.id)}
<VaultListItem
title={char.name}
subtitle={char.traits.slice(0, 3).join(', ')}
onclick={() => addNpcFromCharacter(char)}
class="hover:bg-surface-800 border-transparent bg-transparent"
>
{#snippet icon()}
<Avatar.Root class="h-10 w-10 border shadow-sm">
<Avatar.Image
src={normalizeImageDataUrl(char.portrait) ?? ""}
alt={char.name}
class="object-cover"
/>
<Avatar.Fallback class="bg-muted text-muted-foreground">
<User class="h-5 w-5" />
</Avatar.Fallback>
</Avatar.Root>
{/snippet}
{#snippet end()}
<Plus class="h-4 w-4 text-surface-500 group-hover:text-accent-400 opacity-0 group-hover:opacity-100 transition-opacity" />
{/snippet}
</VaultListItem>
{/each}
{/if}
<div class="p-4 border-b">
<div class="relative">
<Search
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
/>
<Input
bind:value={charSearchQuery}
placeholder="Search characters..."
class="pl-9"
autoFocus
/>
</div>
</div>
</div>
{/if}
</ResponsiveModal.Root>
<div class="flex-1 overflow-y-auto p-2">
{#if filteredCharacters.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Users class="h-8 w-8 mb-2 opacity-50" />
<p>No characters found</p>
</div>
{:else}
{#each filteredCharacters as char}
<button
class="w-full flex items-center gap-3 p-3 rounded-md hover:bg-muted text-left transition-colors"
onclick={() => addNpcFromCharacter(char)}
>
<Avatar.Root class="h-10 w-10 border">
<Avatar.Image
src={normalizeImageDataUrl(char.portrait) ?? ""}
class="object-cover"
/>
<Avatar.Fallback><User class="h-5 w-5" /></Avatar.Fallback>
</Avatar.Root>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{char.name}</div>
<div class="text-xs text-muted-foreground truncate">
{char.traits.slice(0, 3).join(", ")}
</div>
</div>
<Plus class="h-4 w-4 text-muted-foreground" />
</button>
{/each}
{/if}
</div>
</ResponsiveModal.Content>
</ResponsiveModal.Root>
{/if}

View file

@ -116,14 +116,17 @@
<!-- Main Content -->
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex justify-between items-end gap-2">
<div class="flex justify-between items-start gap-2">
<!-- Header info -->
<div class="min-w-0 flex-1">
<h3 class="font-bold text-base leading-none truncate pr-1" {title}>
<h3
class="font-bold text-base leading-normal truncate pr-1"
{title}
>
{title}
</h3>
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap">
{#if badges}
{@render badges()}
{/if}
@ -216,7 +219,7 @@
</div>
{#if description}
<div class="mt-2.5">
<div class="mt-1">
{@render description()}
</div>
{/if}

View file

@ -229,9 +229,24 @@ class DiscoveryService {
return provider.getTags();
}
async getCardDetails(card: DiscoveryCard): Promise<DiscoveryCard> {
const provider = this.providers.get(card.source);
if (!provider) {
// If provider not found or doesn't support fetching details, return original card
return card;
}
if (provider.getCardDetails) {
return provider.getCardDetails(card);
}
return card;
}
/**
* Get tags from all providers (combined and deduplicated)
*/
async getAllTags(type?: 'character' | 'lorebook' | 'scenario'): Promise<string[]> {
const providers = this.getProviders(type);

View file

@ -139,7 +139,49 @@ export class ChubProvider implements DiscoveryProvider {
return await response.blob();
}
async getCardDetails(card: DiscoveryCard): Promise<DiscoveryCard> {
if (card.type === 'lorebook') {
// Lorebooks might have a different structure, but we can try fetching the project definition
return card;
}
try {
const url = `${CHUB_API_BASE}/api/characters/${card.id}?full=true`;
console.log('[Chub] Fetching full details:', url);
const response = await corsFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
console.warn(`[Chub] Failed to fetch details for ${card.id}: ${response.status}`);
return card;
}
const data = await response.json();
// Update the raw data with the full definition
// We assume data.node contains the character definition or data itself is the node
const fullNode = data.node || data;
return {
...card,
// Update specific fields if they were missing/truncated in search
description: fullNode.description || fullNode.tagline || card.description,
raw: {
...card.raw,
...fullNode
}
};
} catch (error) {
console.error('[Chub] Error fetching details:', error);
return card;
}
}
async getTags(): Promise<string[]> {
// Return cached tags if available
if (cachedTags) {
return cachedTags;

View file

@ -43,4 +43,7 @@ export interface DiscoveryProvider {
downloadCard(card: DiscoveryCard): Promise<Blob>;
// Get available tags for filtering (provider-specific)
getTags(): Promise<string[]>;
// Fetch full details for a card (e.g. including alternate greetings, scenario, etc.)
getCardDetails?(card: DiscoveryCard): Promise<DiscoveryCard>;
}

View file

@ -1,105 +0,0 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ["dark"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
screens: {
'xs': '475px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
surface: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
850: '#141b25',
900: '#0f172a',
950: '#020617',
},
},
borderColor: {
DEFAULT: "hsl(var(--border) / <alpha-value>)",
},
ringColor: {
DEFAULT: "hsl(var(--ring) / <alpha-value>)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif', ...fontFamily.sans],
mono: ['JetBrains Mono', 'Fira Code', 'monospace', ...fontFamily.mono],
story: ['Georgia', 'Cambria', 'serif'],
},
},
},
plugins: [tailwindcssAnimate],
};

11
tailwind.config.ts Normal file
View file

@ -0,0 +1,11 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View file

@ -1,11 +1,12 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
export default defineConfig(async () =>({
plugins: [tailwindcss(), sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//