feat: better layout rendering

This commit is contained in:
Alejandro Gómez
2025-12-21 12:57:14 +01:00
parent f255cded75
commit e47fde9158
5 changed files with 191 additions and 146 deletions

View File

@@ -24,14 +24,14 @@ import { toast } from "sonner";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export default function Home() { export default function Home() {
const { const {
state, state,
updateLayout, updateLayout,
removeWindow, removeWindow,
switchToTemporary, switchToTemporary,
applyTemporaryToPersistent, applyTemporaryToPersistent,
discardTemporary, discardTemporary,
isTemporary isTemporary,
} = useGrimoire(); } = useGrimoire();
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false); const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
const { actor, identifier } = useParams(); const { actor, identifier } = useParams();
@@ -94,18 +94,15 @@ export default function Home() {
if (spellbookEvent && !hasLoadedSpellbook) { if (spellbookEvent && !hasLoadedSpellbook) {
try { try {
const parsed = parseSpellbook(spellbookEvent as SpellbookEvent); const parsed = parseSpellbook(spellbookEvent as SpellbookEvent);
// Use the new temporary state system // Use the new temporary state system
switchToTemporary(parsed); switchToTemporary(parsed);
setHasLoadedSpellbook(true); setHasLoadedSpellbook(true);
if (isPreviewPath) { if (isPreviewPath) {
toast.info(`Previewing layout: ${parsed.title}`, { toast.info(`Previewing layout: ${parsed.title}`, {
description: "You are in a temporary session. Apply to keep this layout permanently.", description:
}); "You are in a temporary session. Apply to keep this layout.",
} else if (isDirectPath) {
toast.success(`Loaded temporary layout: ${parsed.title}`, {
description: "Visit / to return to your permanent dashboard, or click Apply Layout.",
}); });
} }
} catch (e) { } catch (e) {
@@ -113,12 +110,18 @@ export default function Home() {
toast.error("Failed to load spellbook"); toast.error("Failed to load spellbook");
} }
} }
}, [spellbookEvent, hasLoadedSpellbook, isPreviewPath, isDirectPath, switchToTemporary]); }, [
spellbookEvent,
hasLoadedSpellbook,
isPreviewPath,
isDirectPath,
switchToTemporary,
]);
const handleApplyLayout = () => { const handleApplyLayout = () => {
applyTemporaryToPersistent(); applyTemporaryToPersistent();
navigate("/", { replace: true }); navigate("/", { replace: true });
toast.success("Layout applied to your dashboard permanently"); toast.success("Layout applied to your dashboard");
}; };
const handleDiscardPreview = () => { const handleDiscardPreview = () => {
@@ -201,22 +204,24 @@ export default function Home() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookHeart className="size-4" /> <BookHeart className="size-4" />
<span> <span>
{isPreviewPath ? "Preview Mode" : "Temporary Layout"}: {spellbookEvent?.tags.find(t => t[0] === 'title')?.[1] || 'Spellbook'} {isPreviewPath ? "Preview Mode" : "Temporary Layout"}:{" "}
{spellbookEvent?.tags.find((t) => t[0] === "title")?.[1] ||
"Spellbook"}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 hover:bg-black/10 text-accent-foreground font-bold" className="h-7 hover:bg-black/10 text-accent-foreground font-bold"
onClick={handleDiscardPreview} onClick={handleDiscardPreview}
> >
<X className="size-3.5 mr-1" /> <X className="size-3.5 mr-1" />
Discard Discard
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="h-7 bg-white text-accent hover:bg-white/90 font-bold shadow-sm" className="h-7 bg-white text-accent hover:bg-white/90 font-bold shadow-sm"
onClick={handleApplyLayout} onClick={handleApplyLayout}
> >
@@ -232,9 +237,8 @@ export default function Home() {
className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-crosshair" className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-crosshair"
title="Launch command (Cmd+K)" title="Launch command (Cmd+K)"
aria-label="Launch command palette" aria-label="Launch command palette"
> ></button>
</button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SpellbookDropdown /> <SpellbookDropdown />
</div> </div>

View File

@@ -6,8 +6,6 @@ import {
Sparkles, Sparkles,
SplitSquareHorizontal, SplitSquareHorizontal,
SplitSquareVertical, SplitSquareVertical,
Save,
BookOpen,
} from "lucide-react"; } from "lucide-react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Slider } from "./ui/slider"; import { Slider } from "./ui/slider";
@@ -23,17 +21,15 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import type { LayoutConfig } from "@/types/app"; import type { LayoutConfig } from "@/types/app";
import { useState } from "react"; import { useState } from "react";
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
export function LayoutControls() { export function LayoutControls() {
const { state, applyPresetLayout, updateLayoutConfig, addWindow } = useGrimoire(); const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
const { workspaces, activeWorkspaceId, layoutConfig } = state; const { workspaces, activeWorkspaceId, layoutConfig } = state;
// Local state for immediate slider feedback (debounced persistence) // Local state for immediate slider feedback (debounced persistence)
const [localSplitPercentage, setLocalSplitPercentage] = useState< const [localSplitPercentage, setLocalSplitPercentage] = useState<
number | null number | null
>(null); >(null);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const activeWorkspace = workspaces[activeWorkspaceId]; const activeWorkspace = workspaces[activeWorkspaceId];
const windowCount = activeWorkspace?.windowIds.length || 0; const windowCount = activeWorkspace?.windowIds.length || 0;
@@ -109,42 +105,19 @@ export function LayoutControls() {
localSplitPercentage ?? layoutConfig.splitPercentage; localSplitPercentage ?? layoutConfig.splitPercentage;
return ( return (
<> <DropdownMenu>
<SaveSpellbookDialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen} /> <DropdownMenuTrigger asChild>
<DropdownMenu> <Button
<DropdownMenuTrigger asChild> variant="ghost"
<Button size="icon"
variant="ghost" className="h-6 w-6"
size="icon" aria-label="Layout settings"
className="h-6 w-6" >
aria-label="Layout settings" <SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
> </Button>
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" /> </DropdownMenuTrigger>
</Button> <DropdownMenuContent align="end" className="w-64">
</DropdownMenuTrigger> {/* Layouts Section */}
<DropdownMenuContent align="end" className="w-64">
{/* Spellbooks Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Spellbooks
</div>
<DropdownMenuItem
onClick={() => setSaveDialogOpen(true)}
className="flex items-center gap-3 cursor-pointer"
>
<Save className="h-4 w-4 text-muted-foreground" />
<div className="font-medium text-sm">Save Layout</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className="flex items-center gap-3 cursor-pointer"
>
<BookOpen className="h-4 w-4 text-muted-foreground" />
<div className="font-medium text-sm">Open Spellbooks</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Layouts Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground"> <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Layout Presets Layout Presets
</div> </div>
@@ -225,6 +198,5 @@ export function LayoutControls() {
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</>
); );
} }

View File

@@ -37,7 +37,7 @@ export function SaveSpellbookDialog({
onOpenChange, onOpenChange,
existingSpellbook, existingSpellbook,
}: SaveSpellbookDialogProps) { }: SaveSpellbookDialogProps) {
const { state } = useGrimoire(); const { state, loadSpellbook } = useGrimoire();
const isUpdateMode = !!existingSpellbook; const isUpdateMode = !!existingSpellbook;
const [title, setTitle] = useState(existingSpellbook?.title || ""); const [title, setTitle] = useState(existingSpellbook?.title || "");
@@ -123,6 +123,17 @@ export function SaveSpellbookDialog({
); );
} }
// 5. Set as active spellbook
const parsedSpellbook = {
slug,
title,
description: description || undefined,
content: localSpellbook.content,
referencedSpells: [],
event: localSpellbook.event as any, // Event might not exist for locally-only spellbooks
};
loadSpellbook(parsedSpellbook);
onOpenChange(false); onOpenChange(false);
// Reset form only if creating new // Reset form only if creating new
if (!isUpdateMode) { if (!isUpdateMode) {

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { BookHeart, ChevronDown, Plus, Save, Settings, X } from "lucide-react"; import { BookHeart, ChevronDown, Plus, Save, Settings, X } from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { useLocation } from "react-router";
import db from "@/services/db"; import db from "@/services/db";
import { useGrimoire } from "@/core/state"; import { useGrimoire } from "@/core/state";
import { useReqTimeline } from "@/hooks/useReqTimeline"; import { useReqTimeline } from "@/hooks/useReqTimeline";
@@ -20,19 +21,32 @@ import { cn } from "@/lib/utils";
import { SaveSpellbookDialog } from "./SaveSpellbookDialog"; import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
export function SpellbookDropdown() { export function SpellbookDropdown() {
const { state, loadSpellbook, addWindow, clearActiveSpellbook, applyTemporaryToPersistent, isTemporary } = const {
useGrimoire(); state,
loadSpellbook,
addWindow,
clearActiveSpellbook,
applyTemporaryToPersistent,
isTemporary,
} = useGrimoire();
const location = useLocation();
const activeAccount = state.activeAccount; const activeAccount = state.activeAccount;
const activeSpellbook = state.activeSpellbook; const activeSpellbook = state.activeSpellbook;
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dialogSpellbook, setDialogSpellbook] = useState<{ const [dialogSpellbook, setDialogSpellbook] = useState<
slug: string; | {
title: string; slug: string;
description?: string; title: string;
workspaceIds?: string[]; description?: string;
localId?: string; workspaceIds?: string[];
pubkey?: string; localId?: string;
} | undefined>(undefined); pubkey?: string;
}
| undefined
>(undefined);
// Check if we're in preview mode
const isPreviewMode = location.pathname.startsWith("/preview/");
// 1. Load Local Data // 1. Load Local Data
const localSpellbooks = useLiveQuery(() => const localSpellbooks = useLiveQuery(() =>
@@ -89,10 +103,11 @@ export function SpellbookDropdown() {
// Check if active spellbook is in local library // Check if active spellbook is in local library
const isActiveLocal = useMemo(() => { const isActiveLocal = useMemo(() => {
if (!activeSpellbook) return false; if (!activeSpellbook) return false;
return (localSpellbooks || []).some(s => s.slug === activeSpellbook.slug); return (localSpellbooks || []).some((s) => s.slug === activeSpellbook.slug);
}, [activeSpellbook, localSpellbooks]); }, [activeSpellbook, localSpellbooks]);
if (!activeAccount || (spellbooks.length === 0 && !activeSpellbook)) { // Show dropdown if: in preview mode, has active account, or has active spellbook
if (!isPreviewMode && !activeAccount && !activeSpellbook) {
return null; return null;
} }
@@ -176,7 +191,8 @@ export function SpellbookDropdown() {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isActiveLocal && activeSpellbook.pubkey === activeAccount.pubkey ? ( {isActiveLocal && activeAccount &&
activeSpellbook.pubkey === activeAccount.pubkey ? (
<DropdownMenuItem <DropdownMenuItem
onClick={handleUpdateActive} onClick={handleUpdateActive}
className={itemClass} className={itemClass}
@@ -205,60 +221,71 @@ export function SpellbookDropdown() {
</> </>
)} )}
{/* Spellbooks Section */} {/* Spellbooks Section - only show if user is logged in */}
<DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold"> {activeAccount && (
My Layouts <>
</DropdownMenuLabel> <DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
My Layouts
{spellbooks.length === 0 ? ( </DropdownMenuLabel>
<div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
No layouts saved yet. {spellbooks.length === 0 ? (
</div> <div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
) : ( No layouts saved yet.
spellbooks.map((sb) => { </div>
const isActive = activeSpellbook?.slug === sb.slug; ) : (
return ( 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 <DropdownMenuItem
key={sb.slug} onClick={handleNewSpellbook}
disabled={isActive} className={itemClass}
onClick={() => handleApplySpellbook(sb)}
className={cn(itemClass, isActive && "bg-muted font-bold")}
> >
<BookHeart <Save className="size-3.5 mr-2 text-muted-foreground" />
className={cn( <span className="text-sm font-medium">Save Spellbook</span>
"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> </DropdownMenuItem>
); )}
})
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className={cn(itemClass, "text-xs opacity-70")}
>
<Settings className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Manage Library</span>
</DropdownMenuItem>
</>
)} )}
<DropdownMenuSeparator /> {/* Show message for non-logged-in users in preview mode */}
{!activeAccount && isPreviewMode && (
{!activeSpellbook && ( <div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
<DropdownMenuItem Log in to save and manage layouts
onClick={handleNewSpellbook} </div>
className={itemClass}
>
<Plus className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Save current as Layout</span>
</DropdownMenuItem>
)} )}
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className={cn(itemClass, "text-xs opacity-70")}
>
<Settings className="size-3.5 mr-2 text-muted-foreground" />
Manage Library
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>

View File

@@ -83,6 +83,41 @@ function LayoutVisualizer({
if (typeof node === "string") { if (typeof node === "string") {
const window = windows[node]; const window = windows[node];
const appId = window?.appId || "unknown"; const appId = window?.appId || "unknown";
// For req windows, show kind badges if available
if (appId === "req" && window?.props?.filter?.kinds) {
const kinds = window.props.filter.kinds;
return (
<div
style={{
flex: 1,
minHeight: "40px",
minWidth: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: "2px",
borderRadius: "4px",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
padding: "4px",
}}
>
{kinds.map((kind: number) => (
<KindBadge
key={kind}
kind={kind}
variant="compact"
className="text-[8px] h-4 px-1"
showName={false}
/>
))}
</div>
);
}
// Default: show appId as text
return ( return (
<div <div
style={{ style={{
@@ -112,6 +147,8 @@ function LayoutVisualizer({
// Branch node - split // Branch node - split
if (node && typeof node === "object" && "first" in node && "second" in node) { if (node && typeof node === "object" && "first" in node && "second" in node) {
const isRow = node.direction === "row"; const isRow = node.direction === "row";
const splitPercentage = node.splitPercentage ?? 50; // Default to 50/50 if not specified
return ( return (
<div <div
style={{ style={{
@@ -123,8 +160,12 @@ function LayoutVisualizer({
minWidth: isRow ? "80px" : "40px", minWidth: isRow ? "80px" : "40px",
}} }}
> >
{renderLayout(node.first)} <div style={{ flexGrow: splitPercentage, minHeight: "40px", minWidth: "40px", display: "flex" }}>
{renderLayout(node.second)} {renderLayout(node.first)}
</div>
<div style={{ flexGrow: 100 - splitPercentage, minHeight: "40px", minWidth: "40px", display: "flex" }}>
{renderLayout(node.second)}
</div>
</div> </div>
); );
} }
@@ -325,26 +366,16 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
<div className="grid gap-4 grid-cols-1"> <div className="grid gap-4 grid-cols-1">
{sortedWorkspaces.map((ws) => { {sortedWorkspaces.map((ws) => {
const wsWindows = ws.windowIds.length;
return ( return (
<div <div
key={ws.id} key={ws.id}
className="flex flex-col gap-3 p-4 rounded-xl border border-border bg-card/50" className="flex flex-col gap-3 p-4 rounded-xl border border-border bg-card/50"
> >
<div className="flex items-center justify-between"> {ws.label && (
<div className="flex flex-col gap-0.5"> <span className="font-bold text-sm">
<span className="text-sm font-mono text-muted-foreground"> {ws.label}
Tab {ws.number} </span>
</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 && ( {ws.layout && (
<LayoutVisualizer <LayoutVisualizer