mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
vault complete
This commit is contained in:
parent
6e0940d368
commit
d4e312ec8d
58 changed files with 4413 additions and 5140 deletions
|
|
@ -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
1423
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
3763
src/app.css
3763
src/app.css
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
|
|
|
|||
304
src/lib/components/discovery/DiscoveryCardDetails.svelte
Normal file
304
src/lib/components/discovery/DiscoveryCardDetails.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
16
src/lib/components/ui/alert/alert-description.svelte
Normal file
16
src/lib/components/ui/alert/alert-description.svelte
Normal 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>
|
||||
25
src/lib/components/ui/alert/alert-title.svelte
Normal file
25
src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||
39
src/lib/components/ui/alert/alert.svelte
Normal file
39
src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||
14
src/lib/components/ui/alert/index.ts
Normal file
14
src/lib/components/ui/alert/index.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
22
src/lib/components/ui/collapsible/collapsible-content.svelte
Normal file
22
src/lib/components/ui/collapsible/collapsible-content.svelte
Normal 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>
|
||||
22
src/lib/components/ui/collapsible/collapsible-trigger.svelte
Normal file
22
src/lib/components/ui/collapsible/collapsible-trigger.svelte
Normal 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>
|
||||
17
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
17
src/lib/components/ui/collapsible/collapsible.svelte
Normal 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}
|
||||
/>
|
||||
12
src/lib/components/ui/collapsible/index.ts
Normal file
12
src/lib/components/ui/collapsible/index.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
50
src/lib/components/ui/dropdown-menu/index.ts
Normal file
50
src/lib/components/ui/dropdown-menu/index.ts
Normal 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,
|
||||
};
|
||||
15
src/lib/components/ui/index.ts
Normal file
15
src/lib/components/ui/index.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
10
src/lib/components/ui/toggle-group/index.ts
Normal file
10
src/lib/components/ui/toggle-group/index.ts
Normal 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,
|
||||
};
|
||||
30
src/lib/components/ui/toggle-group/toggle-group-item.svelte
Normal file
30
src/lib/components/ui/toggle-group/toggle-group-item.svelte
Normal 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}
|
||||
/>
|
||||
41
src/lib/components/ui/toggle-group/toggle-group.svelte
Normal file
41
src/lib/components/ui/toggle-group/toggle-group.svelte
Normal 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}
|
||||
/>
|
||||
13
src/lib/components/ui/toggle/index.ts
Normal file
13
src/lib/components/ui/toggle/index.ts
Normal 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,
|
||||
};
|
||||
51
src/lib/components/ui/toggle/toggle.svelte
Normal file
51
src/lib/components/ui/toggle/toggle.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
11
tailwind.config.ts
Normal 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;
|
||||
|
|
@ -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`
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue