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

View File

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

View File

@@ -37,7 +37,7 @@ export function SaveSpellbookDialog({
onOpenChange,
existingSpellbook,
}: SaveSpellbookDialogProps) {
const { state } = useGrimoire();
const { state, loadSpellbook } = useGrimoire();
const isUpdateMode = !!existingSpellbook;
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);
// Reset form only if creating new
if (!isUpdateMode) {

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { BookHeart, ChevronDown, Plus, Save, Settings, X } from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { useLocation } from "react-router";
import db from "@/services/db";
import { useGrimoire } from "@/core/state";
import { useReqTimeline } from "@/hooks/useReqTimeline";
@@ -20,19 +21,32 @@ import { cn } from "@/lib/utils";
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
export function SpellbookDropdown() {
const { state, loadSpellbook, addWindow, clearActiveSpellbook, applyTemporaryToPersistent, isTemporary } =
useGrimoire();
const {
state,
loadSpellbook,
addWindow,
clearActiveSpellbook,
applyTemporaryToPersistent,
isTemporary,
} = useGrimoire();
const location = useLocation();
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);
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(() =>
@@ -89,10 +103,11 @@ export function SpellbookDropdown() {
// Check if active spellbook is in local library
const isActiveLocal = useMemo(() => {
if (!activeSpellbook) return false;
return (localSpellbooks || []).some(s => s.slug === activeSpellbook.slug);
return (localSpellbooks || []).some((s) => s.slug === activeSpellbook.slug);
}, [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;
}
@@ -176,7 +191,8 @@ export function SpellbookDropdown() {
</DropdownMenuItem>
)}
{isActiveLocal && activeSpellbook.pubkey === activeAccount.pubkey ? (
{isActiveLocal && activeAccount &&
activeSpellbook.pubkey === activeAccount.pubkey ? (
<DropdownMenuItem
onClick={handleUpdateActive}
className={itemClass}
@@ -205,60 +221,71 @@ export function SpellbookDropdown() {
</>
)}
{/* Spellbooks Section */}
<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 (
{/* Spellbooks Section - only show if user is logged in */}
{activeAccount && (
<>
<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
key={sb.slug}
disabled={isActive}
onClick={() => handleApplySpellbook(sb)}
className={cn(itemClass, isActive && "bg-muted font-bold")}
onClick={handleNewSpellbook}
className={itemClass}
>
<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>
<Save className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Save Spellbook</span>
</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 />
{!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>
{/* Show message for non-logged-in users in preview mode */}
{!activeAccount && isPreviewMode && (
<div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
Log in to save and manage layouts
</div>
)}
<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>
</DropdownMenu>
</>

View File

@@ -83,6 +83,41 @@ function LayoutVisualizer({
if (typeof node === "string") {
const window = windows[node];
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 (
<div
style={{
@@ -112,6 +147,8 @@ function LayoutVisualizer({
// Branch node - split
if (node && typeof node === "object" && "first" in node && "second" in node) {
const isRow = node.direction === "row";
const splitPercentage = node.splitPercentage ?? 50; // Default to 50/50 if not specified
return (
<div
style={{
@@ -123,8 +160,12 @@ function LayoutVisualizer({
minWidth: isRow ? "80px" : "40px",
}}
>
{renderLayout(node.first)}
{renderLayout(node.second)}
<div style={{ flexGrow: splitPercentage, minHeight: "40px", minWidth: "40px", display: "flex" }}>
{renderLayout(node.first)}
</div>
<div style={{ flexGrow: 100 - splitPercentage, minHeight: "40px", minWidth: "40px", display: "flex" }}>
{renderLayout(node.second)}
</div>
</div>
);
}
@@ -325,26 +366,16 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
<div className="grid gap-4 grid-cols-1">
{sortedWorkspaces.map((ws) => {
const wsWindows = ws.windowIds.length;
return (
<div
key={ws.id}
className="flex flex-col gap-3 p-4 rounded-xl border border-border bg-card/50"
>
<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.label && (
<span className="font-bold text-sm">
{ws.label}
</span>
)}
{ws.layout && (
<LayoutVisualizer