Files
grimoire/src/components/SpellbookDropdown.tsx
2025-12-21 22:47:24 +01:00

521 lines
16 KiB
TypeScript

import { useMemo, useState } from "react";
import {
BookHeart,
Check,
ChevronDown,
Cloud,
GitFork,
Lock,
Plus,
Save,
Settings,
Share2,
User,
Users,
X,
} from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { useLocation, useNavigate } from "react-router";
import db from "@/services/db";
import { useGrimoire } from "@/core/state";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { parseSpellbook } from "@/lib/spellbook-manager";
import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
import { SPELLBOOK_KIND } from "@/constants/kinds";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
import { toast } from "sonner";
import { UserName } from "./nostr/UserName";
/**
* Status indicator component for spellbook state
*/
function SpellbookStatus({
owner,
isOwner,
isPublished,
isLocal,
className,
}: {
owner?: string;
isOwner: boolean;
isPublished?: boolean;
isLocal?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-1 text-[10px] text-muted-foreground",
className,
)}
>
{/* Ownership */}
{isOwner ? (
<span className="flex items-center gap-0.5" title="Your spellbook">
<User className="size-2.5" />
<span>you</span>
</span>
) : owner ? (
<span className="flex items-center gap-0.5" title="Others' spellbook">
<Users className="size-2.5" />
<UserName pubkey={owner} />
</span>
) : null}
<span className="opacity-50"></span>
{/* Storage status */}
{isPublished ? (
<span
className="flex items-center gap-0.5 text-green-600"
title="Published to Nostr"
>
<Cloud className="size-2.5" />
<span>published</span>
</span>
) : isLocal ? (
<span className="flex items-center gap-0.5" title="Local only">
<Lock className="size-2.5" />
<span>local</span>
</span>
) : (
<span
className="flex items-center gap-0.5"
title="Network only (not in library)"
>
<Cloud className="size-2.5" />
<span>network</span>
</span>
)}
</div>
);
}
export function SpellbookDropdown() {
const {
state,
loadSpellbook,
addWindow,
clearActiveSpellbook,
applyTemporaryToPersistent,
discardTemporary,
isTemporary,
} = useGrimoire();
const location = useLocation();
const navigate = useNavigate();
const activeAccount = state.activeAccount;
const activeSpellbook = state.activeSpellbook;
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dialogSpellbook, setDialogSpellbook] = useState<
| {
slug: string;
title: string;
description?: string;
workspaceIds?: string[];
localId?: string;
pubkey?: string;
}
| undefined
>(undefined);
// Check if we're in preview mode
const isPreviewMode = location.pathname.startsWith("/preview/");
// 1. Load Local Data
const localSpellbooks = useLiveQuery(() =>
db.spellbooks.toArray().then((books) => books.filter((b) => !b.deletedAt)),
);
// 2. Fetch Network Data
const { events: networkEvents } = useReqTimeline(
activeAccount ? `header-spellbooks-${activeAccount.pubkey}` : "none",
activeAccount
? { kinds: [SPELLBOOK_KIND], authors: [activeAccount.pubkey] }
: [],
activeAccount?.relays?.map((r) => r.url) || [],
{ stream: true },
);
// 3. Process Spellbooks
const spellbooks = useMemo(() => {
if (!activeAccount) return [];
const allMap = new Map<string, ParsedSpellbook>();
for (const s of localSpellbooks || []) {
allMap.set(s.slug, {
slug: s.slug,
title: s.title,
description: s.description,
content: s.content,
referencedSpells: [],
event: s.event as SpellbookEvent,
localId: s.id,
isPublished: s.isPublished,
source: "local",
});
}
for (const event of networkEvents) {
const slug = event.tags.find((t) => t[0] === "d")?.[1] || "";
if (!slug) continue;
const existing = allMap.get(slug);
if (
existing &&
event.created_at * 1000 <= (existing.event?.created_at || 0) * 1000
)
continue;
try {
const parsed = parseSpellbook(event as SpellbookEvent);
allMap.set(slug, {
...parsed,
localId: existing?.localId,
isPublished: true,
source: existing?.localId ? "local" : "network",
});
} catch (_e) {
// ignore
}
}
return Array.from(allMap.values()).sort((a, b) =>
a.title.localeCompare(b.title),
);
}, [localSpellbooks, networkEvents, activeAccount]);
const owner = activeSpellbook?.pubkey;
// Derived states for clearer UX
const isOwner = useMemo(() => {
if (!activeSpellbook) return false;
// Owner if: no pubkey (local-only) OR pubkey matches active account
return (
!activeSpellbook.pubkey ||
activeSpellbook.pubkey === activeAccount?.pubkey
);
}, [activeSpellbook, activeAccount]);
const isInLibrary = useMemo(() => {
if (!activeSpellbook) return false;
return !!activeSpellbook.localId;
}, [activeSpellbook]);
// Show dropdown if: in preview mode, has active account, or has active spellbook
if (!isPreviewMode && !activeAccount && !activeSpellbook) {
return null;
}
const handleLoadSpellbook = (sb: ParsedSpellbook) => {
loadSpellbook(sb);
toast.success(`Loaded "${sb.title}"`);
};
const handleUpdateSpellbook = async () => {
if (!activeSpellbook) return;
const local = await db.spellbooks
.where("slug")
.equals(activeSpellbook.slug)
.first();
setDialogSpellbook({
slug: activeSpellbook.slug,
title: activeSpellbook.title,
description: local?.description || activeSpellbook.description,
workspaceIds: Object.keys(state.workspaces),
localId: local?.id || activeSpellbook.localId,
pubkey: activeSpellbook.pubkey,
});
setSaveDialogOpen(true);
};
const handleForkSpellbook = () => {
if (!activeSpellbook) return;
// Open save dialog without existing spellbook to create a new one
setDialogSpellbook(undefined);
setSaveDialogOpen(true);
};
const handleNewSpellbook = () => {
setDialogSpellbook(undefined);
setSaveDialogOpen(true);
};
const handleApplyToMain = () => {
applyTemporaryToPersistent();
navigate("/", { replace: true });
toast.success("Spellbook applied to your dashboard");
};
const handleExitPreview = () => {
discardTemporary();
navigate("/", { replace: true });
};
const handleCloseSpellbook = () => {
if (isTemporary) {
discardTemporary();
navigate("/", { replace: true });
toast.info("Returned to your dashboard");
} else {
clearActiveSpellbook();
toast.info("Spellbook closed");
}
};
const itemClass =
"cursor-pointer py-2 hover:bg-muted focus:bg-muted transition-colors";
return (
<>
<SaveSpellbookDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
existingSpellbook={isOwner && isInLibrary ? dialogSpellbook : undefined}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 px-2 gap-1.5 text-muted-foreground hover:text-foreground",
activeSpellbook && "text-foreground font-medium",
isTemporary && "ring-1 ring-amber-500/50",
)}
>
<BookHeart
className={cn("size-4", isTemporary && "text-amber-500")}
/>
<span className="text-xs font-medium max-w-[100px] sm:max-w-[120px] truncate">
{activeSpellbook ? activeSpellbook.title : "grimoire"}
</span>
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-72 max-h-[80vh] overflow-y-auto"
>
{/* Preview Mode Banner */}
{isPreviewMode && (
<>
<div className="px-3 py-2 bg-amber-500/10 border-b border-amber-500/20">
<div className="flex items-center gap-2 text-amber-600 text-xs font-medium">
<BookHeart className="size-3.5" />
Preview Mode
</div>
<p className="text-[10px] text-muted-foreground mt-1">
You're viewing a shared spellbook. Apply to keep it.
</p>
</div>
{activeSpellbook && (
<div className="px-3 py-2 border-b">
<div className="font-medium text-sm truncate">
{activeSpellbook.title}
</div>
<SpellbookStatus
owner={owner}
isOwner={isOwner}
isPublished={activeSpellbook.isPublished}
isLocal={isInLibrary}
className="mt-1"
/>
</div>
)}
<DropdownMenuItem
onClick={handleApplyToMain}
className={cn(
itemClass,
"bg-green-500/5 text-green-600 font-medium",
)}
>
<Check className="size-3.5 mr-2" />
Apply to Dashboard
</DropdownMenuItem>
{activeAccount && (
<DropdownMenuItem
onClick={handleForkSpellbook}
className={itemClass}
>
<GitFork className="size-3.5 mr-2 text-muted-foreground" />
Fork to Library
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleExitPreview}
className={cn(itemClass, "text-muted-foreground")}
>
<X className="size-3.5 mr-2" />
Exit Preview
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Active Spellbook Section (non-preview) */}
{activeSpellbook && !isPreviewMode && (
<>
<DropdownMenuLabel className="py-1 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
Active Spellbook
</DropdownMenuLabel>
<div className="px-3 py-2 border-b">
<div className="font-medium text-sm truncate">
{activeSpellbook.title}
</div>
<SpellbookStatus
owner={activeSpellbook.pubkey}
isOwner={isOwner}
isPublished={activeSpellbook.isPublished}
isLocal={isInLibrary}
className="mt-1"
/>
</div>
{/* Temporary session actions */}
{isTemporary && (
<DropdownMenuItem
onClick={handleApplyToMain}
className={cn(itemClass, "bg-amber-500/5 font-medium")}
>
<Check className="size-3.5 mr-2 text-amber-600" />
Keep This Spellbook
</DropdownMenuItem>
)}
{/* Owner actions */}
{isOwner && isInLibrary && (
<DropdownMenuItem
onClick={handleUpdateSpellbook}
className={itemClass}
>
<Save className="size-3.5 mr-2 text-muted-foreground" />
Update & Publish
</DropdownMenuItem>
)}
{/* Non-owner or not in library actions */}
{(!isOwner || !isInLibrary) && activeAccount && (
<DropdownMenuItem
onClick={handleForkSpellbook}
className={itemClass}
>
<GitFork className="size-3.5 mr-2 text-muted-foreground" />
{isOwner ? "Save to Library" : "Fork to Library"}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className={cn(itemClass, "text-muted-foreground text-xs")}
>
<Share2 className="size-3.5 mr-2" />
Share...
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleCloseSpellbook}
className={cn(itemClass, "text-muted-foreground text-xs")}
>
<X className="size-3.5 mr-2" />
Close Spellbook
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* My Spellbooks Section */}
{activeAccount && (
<>
<DropdownMenuLabel className="flex items-center justify-between py-1 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
My Spellbooks
</DropdownMenuLabel>
{spellbooks.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground italic">
No spellbooks saved yet.
</div>
) : (
spellbooks.map((sb) => {
const isActive = activeSpellbook?.slug === sb.slug;
return (
<DropdownMenuItem
key={sb.slug}
disabled={isActive}
onClick={() => handleLoadSpellbook(sb)}
className={cn(
itemClass,
"flex items-center gap-2",
isActive && "bg-muted",
)}
>
<BookHeart
className={cn(
"size-3.5 flex-shrink-0 text-muted-foreground",
isActive && "text-foreground",
)}
/>
<span
className={cn(
"truncate flex-1 text-sm",
isActive && "font-medium",
)}
>
{sb.title}
</span>
{/* Status badge */}
{sb.isPublished ? (
<Cloud
className="size-3 text-green-600 flex-shrink-0"
aria-label="Published"
/>
) : (
<Lock
className="size-3 text-muted-foreground flex-shrink-0"
aria-label="Local only"
/>
)}
</DropdownMenuItem>
);
})
)}
<DropdownMenuSeparator />
{/* Actions */}
<DropdownMenuItem
onClick={handleNewSpellbook}
className={itemClass}
>
<Plus className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm">Save Current as Spellbook</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className={cn(itemClass, "text-muted-foreground")}
>
<Settings className="size-3.5 mr-2" />
<span className="text-sm">Manage Library</span>
</DropdownMenuItem>
</>
)}
{/* Non-logged-in user in preview mode */}
{!activeAccount && isPreviewMode && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground italic">
Log in to save and manage spellbooks
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
);
}