mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
feat: refine spellbook preview and session management logic
- Implement smart banner visibility (only from client-side transitions) - Add 'Apply to Dashboard' and 'Add to Library' to SpellbookDropdown - Support updating layouts via standardized dialog - Fix build errors and type mismatches
This commit is contained in:
@@ -42,8 +42,12 @@ export default function Home() {
|
||||
const [resolvedPubkey, setResolvedPubkey] = useState<string | null>(null);
|
||||
const isPreviewPath = location.pathname.startsWith("/preview/");
|
||||
const isDirectPath = actor && identifier && !isPreviewPath;
|
||||
const isFromApp = location.state?.fromApp === true;
|
||||
const [hasLoadedSpellbook, setHasLoadedSpellbook] = useState(false);
|
||||
|
||||
// Show banner only if temporary AND we navigated from within the app
|
||||
const showBanner = isTemporary && isFromApp;
|
||||
|
||||
// 1. Resolve actor to pubkey
|
||||
useEffect(() => {
|
||||
if (!actor) {
|
||||
@@ -192,7 +196,7 @@ export default function Home() {
|
||||
/>
|
||||
<GlobalAuthPrompt />
|
||||
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
|
||||
{isTemporary && (
|
||||
{showBanner && (
|
||||
<div className="bg-accent text-accent-foreground px-4 py-1.5 flex items-center justify-between text-sm font-medium animate-in slide-in-from-top duration-300 shadow-md z-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookHeart className="size-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,21 +22,48 @@ import { Loader2, Save, Send } from "lucide-react";
|
||||
interface SaveSpellbookDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
existingSpellbook?: {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
workspaceIds?: string[];
|
||||
localId?: string;
|
||||
pubkey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SaveSpellbookDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
existingSpellbook,
|
||||
}: SaveSpellbookDialogProps) {
|
||||
const { state } = useGrimoire();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const isUpdateMode = !!existingSpellbook;
|
||||
|
||||
const [title, setTitle] = useState(existingSpellbook?.title || "");
|
||||
const [description, setDescription] = useState(existingSpellbook?.description || "");
|
||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>(
|
||||
Object.keys(state.workspaces),
|
||||
existingSpellbook?.workspaceIds || Object.keys(state.workspaces),
|
||||
);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Update form when dialog opens with existing spellbook data
|
||||
useEffect(() => {
|
||||
if (open && existingSpellbook) {
|
||||
setTitle(existingSpellbook.title);
|
||||
setDescription(existingSpellbook.description || "");
|
||||
setSelectedWorkspaces(
|
||||
existingSpellbook.workspaceIds || Object.keys(state.workspaces),
|
||||
);
|
||||
} else if (open && !existingSpellbook) {
|
||||
// Reset form for new spellbook
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSelectedWorkspaces(Object.keys(state.workspaces));
|
||||
}
|
||||
}, [open, existingSpellbook, state.workspaces]);
|
||||
|
||||
const handleSave = async (shouldPublish: boolean) => {
|
||||
if (!title.trim()) {
|
||||
toast.error("Please enter a title for your spellbook");
|
||||
@@ -44,7 +71,7 @@ export function SaveSpellbookDialog({
|
||||
}
|
||||
|
||||
if (selectedWorkspaces.length === 0) {
|
||||
toast.error("Please select at least one workspace to include");
|
||||
toast.error("Please select at least one tab to include");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,16 +87,21 @@ export function SaveSpellbookDialog({
|
||||
workspaceIds: selectedWorkspaces,
|
||||
});
|
||||
|
||||
// 2. Save locally
|
||||
// 2. Determine slug (keep existing for updates, generate for new)
|
||||
const slug = isUpdateMode
|
||||
? existingSpellbook.slug
|
||||
: title.toLowerCase().trim().replace(/\s+/g, "-");
|
||||
|
||||
// 3. Save locally
|
||||
const localSpellbook = await saveSpellbook({
|
||||
slug: title.toLowerCase().trim().replace(/\s+/g, "-"),
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
content: JSON.parse(encoded.eventProps.content),
|
||||
isPublished: false,
|
||||
});
|
||||
|
||||
// 3. Optionally publish
|
||||
// 4. Optionally publish
|
||||
if (shouldPublish) {
|
||||
const action = new PublishSpellbookAction();
|
||||
await action.execute({
|
||||
@@ -77,21 +109,32 @@ export function SaveSpellbookDialog({
|
||||
title,
|
||||
description,
|
||||
workspaceIds: selectedWorkspaces,
|
||||
localId: localSpellbook.id,
|
||||
localId: existingSpellbook?.localId || localSpellbook.id,
|
||||
content: localSpellbook.content, // Pass explicitly to avoid re-calculating (and potentially failing)
|
||||
});
|
||||
toast.success("Spellbook saved and published to Nostr");
|
||||
toast.success(
|
||||
isUpdateMode
|
||||
? "Spellbook updated and published to Nostr"
|
||||
: "Spellbook saved and published to Nostr",
|
||||
);
|
||||
} else {
|
||||
toast.success("Spellbook saved locally");
|
||||
toast.success(
|
||||
isUpdateMode ? "Spellbook updated locally" : "Spellbook saved locally",
|
||||
);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
// Reset form only if creating new
|
||||
if (!isUpdateMode) {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSelectedWorkspaces(Object.keys(state.workspaces));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save spellbook:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save spellbook");
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save spellbook",
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsPublishing(false);
|
||||
@@ -102,9 +145,13 @@ export function SaveSpellbookDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Layout as Spellbook</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isUpdateMode ? "Update Spellbook" : "Save Layout as Spellbook"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save your current workspaces and window configuration.
|
||||
{isUpdateMode
|
||||
? "Update the configuration of your spellbook."
|
||||
: "Save your current workspaces and window configuration."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -130,7 +177,7 @@ export function SaveSpellbookDialog({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Workspaces to include</Label>
|
||||
<Label>Tabs to include</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
{Object.values(state.workspaces)
|
||||
.sort((a, b) => a.number - b.number)
|
||||
@@ -153,7 +200,7 @@ export function SaveSpellbookDialog({
|
||||
htmlFor={`ws-${ws.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{ws.number}. {ws.label || "Workspace"}
|
||||
{ws.number}. {ws.label || "Tab"}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { BookHeart, ChevronDown, Plus, Save, X } from "lucide-react";
|
||||
import { BookHeart, ChevronDown, Plus, Save, Settings, X } from "lucide-react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import db from "@/services/db";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useReqTimeline } from "@/hooks/useReqTimeline";
|
||||
import { createSpellbook, parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import { parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
|
||||
import { SPELLBOOK_KIND } from "@/constants/kinds";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -16,19 +16,23 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublishSpellbookAction } from "@/actions/publish-spellbook";
|
||||
import { saveSpellbook } from "@/services/spellbook-storage";
|
||||
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
|
||||
|
||||
export function SpellbookDropdown() {
|
||||
const { state, loadSpellbook, addWindow, clearActiveSpellbook } =
|
||||
const { state, loadSpellbook, addWindow, clearActiveSpellbook, applyTemporaryToPersistent, isTemporary } =
|
||||
useGrimoire();
|
||||
const activeAccount = state.activeAccount;
|
||||
const activeSpellbook = state.activeSpellbook;
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [dialogSpellbook, setDialogSpellbook] = useState<{
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
workspaceIds?: string[];
|
||||
localId?: string;
|
||||
pubkey?: string;
|
||||
} | undefined>(undefined);
|
||||
|
||||
// 1. Load Local Data
|
||||
const localSpellbooks = useLiveQuery(() =>
|
||||
@@ -82,63 +86,43 @@ export function SpellbookDropdown() {
|
||||
);
|
||||
}, [localSpellbooks, networkEvents, activeAccount]);
|
||||
|
||||
// Check if active spellbook is in local library
|
||||
const isActiveLocal = useMemo(() => {
|
||||
if (!activeSpellbook) return false;
|
||||
return (localSpellbooks || []).some(s => s.slug === activeSpellbook.slug);
|
||||
}, [activeSpellbook, localSpellbooks]);
|
||||
|
||||
if (!activeAccount || (spellbooks.length === 0 && !activeSpellbook)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleApplySpellbook = (sb: ParsedSpellbook) => {
|
||||
loadSpellbook(sb);
|
||||
toast.success(`Layout "${sb.title}" applied`);
|
||||
};
|
||||
|
||||
const handleUpdateActive = async () => {
|
||||
if (!activeSpellbook) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
// Generate current layout content
|
||||
const encoded = createSpellbook({
|
||||
state,
|
||||
title: activeSpellbook.title,
|
||||
});
|
||||
|
||||
const content = JSON.parse(encoded.eventProps.content);
|
||||
// Get local spellbook for ID
|
||||
const local = await db.spellbooks
|
||||
.where("slug")
|
||||
.equals(activeSpellbook.slug)
|
||||
.first();
|
||||
|
||||
// 1. Save locally
|
||||
const local = await db.spellbooks
|
||||
.where("slug")
|
||||
.equals(activeSpellbook.slug)
|
||||
.first();
|
||||
if (local) {
|
||||
await db.spellbooks.update(local.id, { content });
|
||||
} else {
|
||||
await saveSpellbook({
|
||||
slug: activeSpellbook.slug,
|
||||
title: activeSpellbook.title,
|
||||
content,
|
||||
isPublished: false,
|
||||
});
|
||||
}
|
||||
// Open dialog with existing spellbook data
|
||||
setDialogSpellbook({
|
||||
slug: activeSpellbook.slug,
|
||||
title: activeSpellbook.title,
|
||||
workspaceIds: Object.keys(state.workspaces),
|
||||
localId: local?.id,
|
||||
pubkey: activeSpellbook.pubkey,
|
||||
});
|
||||
setSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
// 2. If it was published or we want to publish updates
|
||||
if (activeSpellbook.pubkey === activeAccount.pubkey) {
|
||||
const action = new PublishSpellbookAction();
|
||||
await action.execute({
|
||||
state,
|
||||
title: activeSpellbook.title,
|
||||
content,
|
||||
localId: local?.id,
|
||||
});
|
||||
toast.success(
|
||||
`Layout "${activeSpellbook.title}" updated and published`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`Layout "${activeSpellbook.title}" updated locally`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Failed to update layout");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
const handleNewSpellbook = () => {
|
||||
setDialogSpellbook(undefined);
|
||||
setSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
const itemClass =
|
||||
@@ -149,6 +133,7 @@ export function SpellbookDropdown() {
|
||||
<SaveSpellbookDialog
|
||||
open={saveDialogOpen}
|
||||
onOpenChange={setSaveDialogOpen}
|
||||
existingSpellbook={isActiveLocal ? dialogSpellbook : undefined}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -162,7 +147,7 @@ export function SpellbookDropdown() {
|
||||
>
|
||||
<BookHeart className="size-4" />
|
||||
<span className="text-xs font-medium hidden sm:inline">
|
||||
{activeSpellbook ? activeSpellbook.title : "Layouts"}
|
||||
{activeSpellbook ? activeSpellbook.title : "grimoire"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
@@ -175,21 +160,40 @@ export function SpellbookDropdown() {
|
||||
{activeSpellbook && (
|
||||
<>
|
||||
<DropdownMenuLabel className="py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
Current Layout
|
||||
Active Layout
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={handleUpdateActive}
|
||||
disabled={isUpdating}
|
||||
className={itemClass}
|
||||
>
|
||||
<Save className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium text-sm">Update</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Save current state to this spellbook
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<div className="px-2 py-1 text-sm font-medium truncate opacity-80 mb-1">
|
||||
{activeSpellbook.title || activeSpellbook.slug}
|
||||
</div>
|
||||
|
||||
{isTemporary && (
|
||||
<DropdownMenuItem
|
||||
onClick={applyTemporaryToPersistent}
|
||||
className={cn(itemClass, "bg-accent/5 font-bold")}
|
||||
>
|
||||
<Save className="size-3.5 mr-2" />
|
||||
Apply to Dashboard
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{isActiveLocal && activeSpellbook.pubkey === activeAccount.pubkey ? (
|
||||
<DropdownMenuItem
|
||||
onClick={handleUpdateActive}
|
||||
className={itemClass}
|
||||
>
|
||||
<Save className="size-3.5 mr-2 text-muted-foreground" />
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={handleUpdateActive}
|
||||
className={itemClass}
|
||||
>
|
||||
<Plus className="size-3.5 mr-2 text-muted-foreground" />
|
||||
Add to Library
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={clearActiveSpellbook}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
@@ -202,59 +206,61 @@ export function SpellbookDropdown() {
|
||||
)}
|
||||
|
||||
{/* Spellbooks Section */}
|
||||
{spellbooks.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
My Layouts
|
||||
</DropdownMenuLabel>
|
||||
{spellbooks.map((sb) => {
|
||||
const isActive = activeSpellbook?.slug === sb.slug;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sb.slug}
|
||||
onClick={() => handleApplySpellbook(sb)}
|
||||
className={cn(itemClass, isActive && "bg-muted font-bold")}
|
||||
>
|
||||
<BookHeart
|
||||
className={cn(
|
||||
"size-3.5 mr-2 text-muted-foreground",
|
||||
isActive && "text-foreground",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium text-sm">
|
||||
{sb.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{Object.keys(sb.content.workspaces).length} tabs
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem
|
||||
onClick={() => addWindow("spellbooks", {})}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
>
|
||||
<BookHeart className="size-3 mr-2 text-muted-foreground" />
|
||||
Manage Layouts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
<DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
My Layouts
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{spellbooks.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
|
||||
No layouts saved yet.
|
||||
</div>
|
||||
) : (
|
||||
spellbooks.map((sb) => {
|
||||
const isActive = activeSpellbook?.slug === sb.slug;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sb.slug}
|
||||
disabled={isActive}
|
||||
onClick={() => handleApplySpellbook(sb)}
|
||||
className={cn(itemClass, isActive && "bg-muted font-bold")}
|
||||
>
|
||||
<BookHeart
|
||||
className={cn(
|
||||
"size-3.5 mr-2 text-muted-foreground",
|
||||
isActive && "text-foreground",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row gap-0 min-w-0">
|
||||
<span className="truncate font-medium text-sm">
|
||||
{sb.title}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{!activeSpellbook && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleNewSpellbook}
|
||||
className={itemClass}
|
||||
>
|
||||
<Plus className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Save current as Layout</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* New Section */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
className={itemClass}
|
||||
onClick={() => addWindow("spellbooks", {})}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
>
|
||||
<Plus className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Save as new layout
|
||||
</span>
|
||||
<Settings className="size-3.5 mr-2 text-muted-foreground" />
|
||||
Manage Library
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
import { parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { BookHeart, Layout, ExternalLink, Play, Eye, Share2 } from "lucide-react";
|
||||
import { Layout, ExternalLink, Eye, Share2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { toast } from "sonner";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { nip19 } from "nostr-tools";
|
||||
@@ -36,25 +35,30 @@ function getSpellbookKinds(spellbook: ParsedSpellbook): number[] {
|
||||
* Preview Button Component
|
||||
* Navigates to /<npub|nip05>/<identifier>
|
||||
*/
|
||||
function PreviewButton({ event, identifier, size = "default", className = "" }: {
|
||||
event: NostrEvent,
|
||||
identifier: string,
|
||||
size?: "default" | "sm" | "lg" | "icon",
|
||||
className?: string
|
||||
function PreviewButton({
|
||||
event,
|
||||
identifier,
|
||||
size = "default",
|
||||
className = "",
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
identifier: string;
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
className?: string;
|
||||
}) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const handlePreview = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
|
||||
navigate(`/preview/${actor}/${identifier}`);
|
||||
navigate(`/preview/${actor}/${identifier}`, { state: { fromApp: true } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={handlePreview}
|
||||
className={`flex items-center gap-2 ${className}`}
|
||||
>
|
||||
@@ -64,6 +68,87 @@ function PreviewButton({ event, identifier, size = "default", className = "" }:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a visual representation of the window layout using flex boxes
|
||||
*/
|
||||
function LayoutVisualizer({
|
||||
layout,
|
||||
windows,
|
||||
}: {
|
||||
layout: any;
|
||||
windows: Record<string, WindowInstance>;
|
||||
}) {
|
||||
const renderLayout = (node: any): React.ReactNode => {
|
||||
// Leaf node - single window
|
||||
if (typeof node === "string") {
|
||||
const window = windows[node];
|
||||
const appId = window?.appId || "unknown";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: "40px",
|
||||
minWidth: "40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: 500,
|
||||
borderRadius: "4px",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
background: "hsl(var(--muted))",
|
||||
padding: "4px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={appId}
|
||||
>
|
||||
{appId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch node - split
|
||||
if (node && typeof node === "object" && "first" in node && "second" in node) {
|
||||
const isRow = node.direction === "row";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: isRow ? "row" : "column",
|
||||
gap: "4px",
|
||||
flex: 1,
|
||||
minHeight: isRow ? "40px" : "80px",
|
||||
minWidth: isRow ? "80px" : "40px",
|
||||
}}
|
||||
>
|
||||
{renderLayout(node.first)}
|
||||
{renderLayout(node.second)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "60px",
|
||||
display: "flex",
|
||||
padding: "8px",
|
||||
borderRadius: "8px",
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
}}
|
||||
>
|
||||
{renderLayout(layout)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for Kind 30777 - Spellbook (Layout Configuration)
|
||||
* Displays spellbook title, description, and counts in feed
|
||||
@@ -96,15 +181,12 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<BookHeart className="size-4 text-accent flex-shrink-0" />
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-bold text-foreground truncate"
|
||||
>
|
||||
{spellbook.title}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-bold text-foreground truncate"
|
||||
>
|
||||
{spellbook.title}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Description */}
|
||||
{spellbook.description && (
|
||||
@@ -114,7 +196,12 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviewButton event={event} identifier={spellbook.slug} size="sm" className="flex-shrink-0" />
|
||||
<PreviewButton
|
||||
event={event}
|
||||
identifier={spellbook.slug}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kind Badges */}
|
||||
@@ -151,10 +238,9 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 30777 - Spellbook
|
||||
* Shows detailed workspace information and Apply Layout button
|
||||
* Shows detailed workspace information with preview and sharing options
|
||||
*/
|
||||
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { loadSpellbook } = useGrimoire();
|
||||
const profile = useProfile(event.pubkey);
|
||||
|
||||
const spellbook = useMemo(() => {
|
||||
@@ -173,13 +259,6 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
loadSpellbook(spellbook);
|
||||
toast.success("Layout applied", {
|
||||
description: `Replaced current layout with ${Object.keys(spellbook.content.workspaces).length} workspaces.`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
|
||||
const url = `${window.location.origin}/${actor}/${spellbook.slug}`;
|
||||
@@ -194,111 +273,90 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between border-b border-border/50 pb-6">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-accent/10 rounded-xl">
|
||||
<BookHeart className="size-8 text-accent" />
|
||||
</div>
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-3xl font-bold truncate hover:underline cursor-pointer"
|
||||
>
|
||||
{spellbook.title}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
{getSpellbookKinds(spellbook).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 py-1">
|
||||
{getSpellbookKinds(spellbook).map((kind) => (
|
||||
<KindBadge
|
||||
key={kind}
|
||||
kind={kind}
|
||||
showName
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4 border-b border-border/50 pb-6">
|
||||
<h1 className="text-3xl font-bold truncate">{spellbook.title}</h1>
|
||||
|
||||
{spellbook.description && (
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{spellbook.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{spellbook.description && (
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{spellbook.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyLink}
|
||||
className="flex items-center gap-2 h-12 px-5"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
<Share2 className="size-4" />
|
||||
Share Link
|
||||
</Button>
|
||||
|
||||
<PreviewButton
|
||||
event={event}
|
||||
identifier={spellbook.slug}
|
||||
size="lg"
|
||||
|
||||
<PreviewButton
|
||||
event={event}
|
||||
identifier={spellbook.slug}
|
||||
size="sm"
|
||||
className="bg-background"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleApply}
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground flex items-center gap-2 h-12 px-6 text-lg font-bold"
|
||||
>
|
||||
<Play className="size-5 fill-current" />
|
||||
Apply Layout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspaces Summary */}
|
||||
{/* Event Kinds */}
|
||||
{getSpellbookKinds(spellbook).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Event Kinds
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{getSpellbookKinds(spellbook).map((kind) => (
|
||||
<KindBadge key={kind} kind={kind} showName clickable />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs Summary */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<Layout className="size-4" />
|
||||
Workspaces Content
|
||||
Tabs
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
<div className="grid gap-4 grid-cols-1">
|
||||
{sortedWorkspaces.map((ws) => {
|
||||
const wsWindows = ws.windowIds.length;
|
||||
return (
|
||||
<div
|
||||
key={ws.id}
|
||||
className="p-4 rounded-xl border border-border bg-card/50 flex items-center justify-between"
|
||||
className="flex flex-col gap-3 p-4 rounded-xl border border-border bg-card/50"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
Workspace {ws.number}
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{ws.label || "Untitled Workspace"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-muted rounded-full text-xs font-medium">
|
||||
<ExternalLink className="size-3" />
|
||||
{wsWindows} {wsWindows === 1 ? "window" : "windows"}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
Tab {ws.number}
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{ws.label || "Untitled Tab"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-muted rounded-full text-xs font-medium">
|
||||
<ExternalLink className="size-3" />
|
||||
{wsWindows} {wsWindows === 1 ? "window" : "windows"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ws.layout && (
|
||||
<LayoutVisualizer
|
||||
layout={ws.layout}
|
||||
windows={spellbook.content.windows}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Data / Reference */}
|
||||
<div className="mt-8 pt-8 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground font-mono">
|
||||
<div className="flex gap-4">
|
||||
<span>D-TAG: {spellbook.slug}</span>
|
||||
<span>VERSION: {spellbook.content.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user