mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
feat: better layout rendering
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user