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:
Alejandro Gómez
2025-12-20 22:15:30 +01:00
parent 588980a827
commit f255cded75
4 changed files with 356 additions and 241 deletions

View File

@@ -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" />

View File

@@ -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>
))}

View File

@@ -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>
</>
);
}
}

View File

@@ -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>
);
}