mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: improve spellbook UX with active tracking and update capability
- Add activeSpellbook to GrimoireState - Display active spellbook title in header with clear button - Add 'Update Layout' and 'Save as new' to SpellbookDropdown - Highlight active spellbook in dropdown list - Add 'Manage Spells' link to dropdown - Refine dropdown styles (muted hover, no accent color)
This commit is contained in:
@@ -9,7 +9,7 @@ import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component";
|
||||
import CommandLauncher from "./CommandLauncher";
|
||||
import { WindowToolbar } from "./WindowToolbar";
|
||||
import { WindowTile } from "./WindowTitle";
|
||||
import { Terminal, BookHeart, X, Check } from "lucide-react";
|
||||
import { BookHeart, X, Check } from "lucide-react";
|
||||
import UserMenu from "./nostr/user-menu";
|
||||
import { GrimoireWelcome } from "./GrimoireWelcome";
|
||||
import { GlobalAuthPrompt } from "./GlobalAuthPrompt";
|
||||
@@ -26,12 +26,14 @@ import { Button } from "./ui/button";
|
||||
const PREVIEW_BACKUP_KEY = "grimoire-preview-backup";
|
||||
|
||||
export default function Home() {
|
||||
const { state, updateLayout, removeWindow, loadSpellbook } = useGrimoire();
|
||||
const { state, updateLayout, removeWindow, loadSpellbook, clearActiveSpellbook } = useGrimoire();
|
||||
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
|
||||
const { actor, identifier } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const activeSpellbook = state.activeSpellbook;
|
||||
|
||||
// Preview state
|
||||
const [resolvedPubkey, setResolvedPubkey] = useState<string | null>(null);
|
||||
const isPreviewPath = location.pathname.startsWith("/preview/");
|
||||
@@ -241,10 +243,24 @@ export default function Home() {
|
||||
title="Launch command (Cmd+K)"
|
||||
aria-label="Launch command palette"
|
||||
>
|
||||
<Terminal className="size-4" />
|
||||
</button>
|
||||
|
||||
<SpellbookDropdown />
|
||||
<div className="flex items-center gap-2">
|
||||
<SpellbookDropdown />
|
||||
{activeSpellbook && !isPreviewPath && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-muted/50 border border-border animate-in fade-in zoom-in duration-300">
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Active:</span>
|
||||
<span className="text-xs font-medium truncate max-w-[150px]">{activeSpellbook.title}</span>
|
||||
<button
|
||||
onClick={clearActiveSpellbook}
|
||||
className="ml-1 p-0.5 hover:bg-background rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Clear active spellbook context"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { BookHeart, ChevronDown, WandSparkles } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BookHeart, ChevronDown, Plus, Save, WandSparkles } 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 { parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import { createSpellbook, parseSpellbook } from "@/lib/spellbook-manager";
|
||||
import { decodeSpell } from "@/lib/spell-conversion";
|
||||
import type {
|
||||
SpellbookEvent,
|
||||
@@ -25,10 +25,16 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { manPages } from "@/types/man";
|
||||
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 } = useGrimoire();
|
||||
const activeAccount = state.activeAccount;
|
||||
const activeSpellbook = state.activeSpellbook;
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// 1. Load Local Data
|
||||
const localSpellbooks = useLiveQuery(() =>
|
||||
@@ -130,6 +136,51 @@ export function SpellbookDropdown() {
|
||||
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);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 handleRunSpell = async (spell: ParsedSpell) => {
|
||||
try {
|
||||
const parts = spell.command.trim().split(/\s+/);
|
||||
@@ -153,90 +204,131 @@ export function SpellbookDropdown() {
|
||||
"cursor-pointer py-2 hover:bg-muted focus:bg-muted transition-colors";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
<>
|
||||
<SaveSpellbookDialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-2 gap-1.5 text-muted-foreground hover:text-foreground",
|
||||
activeSpellbook && "text-foreground font-bold"
|
||||
)}
|
||||
>
|
||||
<BookHeart className="size-4" />
|
||||
<span className="text-xs font-medium hidden sm:inline">
|
||||
{activeSpellbook ? activeSpellbook.title : "Library"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-64 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
<BookHeart className="size-4" />
|
||||
<span className="text-xs font-medium hidden sm:inline">Library</span>
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-64 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
{/* 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">
|
||||
Spellbooks
|
||||
</DropdownMenuLabel>
|
||||
{spellbooks.map((sb) => (
|
||||
{/* Active Spellbook Actions */}
|
||||
{activeSpellbook && (
|
||||
<>
|
||||
<DropdownMenuLabel className="py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
Current Layout
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
key={sb.slug}
|
||||
onClick={() => handleApplySpellbook(sb)}
|
||||
onClick={handleUpdateActive}
|
||||
disabled={isUpdating}
|
||||
className={itemClass}
|
||||
>
|
||||
<BookHeart className="size-3.5 mr-2 text-muted-foreground flex-shrink-0" />
|
||||
<Save className="size-3.5 mr-2 text-muted-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>
|
||||
<span className="font-medium text-sm">Update "{activeSpellbook.title}"</span>
|
||||
<span className="text-[10px] text-muted-foreground">Save current state to this spellbook</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() => addWindow("spellbooks", {})}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
>
|
||||
<BookHeart className="size-3 mr-2 text-muted-foreground" />
|
||||
Manage Library
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spells Section */}
|
||||
{spells.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
Spells
|
||||
</DropdownMenuLabel>
|
||||
{spells.map((s, idx) => (
|
||||
{/* 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">
|
||||
Spellbooks
|
||||
</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
|
||||
key={s.event?.id || `local-${idx}`}
|
||||
onClick={() => handleRunSpell(s)}
|
||||
className={itemClass}
|
||||
onClick={() => addWindow("spellbooks", {})}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
>
|
||||
<WandSparkles className="size-3.5 mr-2 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium text-sm">
|
||||
{s.name || "Untitled Spell"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate font-mono">
|
||||
{s.command}
|
||||
</span>
|
||||
</div>
|
||||
<BookHeart className="size-3 mr-2 text-muted-foreground" />
|
||||
Manage Library
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() => addWindow("spells", {})}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
>
|
||||
<WandSparkles className="size-3 mr-2 text-muted-foreground" />
|
||||
Manage Spells
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* New/Save Section */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
className={itemClass}
|
||||
>
|
||||
<Plus className="size-3.5 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm">Save as new layout</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Spells Section */}
|
||||
{spells.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
Spells
|
||||
</DropdownMenuLabel>
|
||||
{spells.map((s, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={s.event?.id || `local-${idx}`}
|
||||
onClick={() => handleRunSpell(s)}
|
||||
className={itemClass}
|
||||
>
|
||||
<WandSparkles className="size-3.5 mr-2 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium text-sm">
|
||||
{s.name || "Untitled Spell"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate font-mono">
|
||||
{s.command}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() => addWindow("spells", {})}
|
||||
className={cn(itemClass, "text-xs opacity-70")}
|
||||
>
|
||||
<WandSparkles className="size-3 mr-2 text-muted-foreground" />
|
||||
Manage Spells
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -491,3 +491,13 @@ export const setCompactModeKinds = (
|
||||
compactModeKinds: kinds,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the currently active spellbook tracking.
|
||||
*/
|
||||
export const clearActiveSpellbook = (state: GrimoireState): GrimoireState => {
|
||||
return {
|
||||
...state,
|
||||
activeSpellbook: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -306,6 +306,11 @@ export const useGrimoire = () => {
|
||||
[setState],
|
||||
);
|
||||
|
||||
const clearActiveSpellbook = useCallback(
|
||||
() => setState((prev) => Logic.clearActiveSpellbook(prev)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
@@ -326,5 +331,6 @@ export const useGrimoire = () => {
|
||||
reorderWorkspaces,
|
||||
setCompactModeKinds,
|
||||
loadSpellbook,
|
||||
clearActiveSpellbook,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -266,5 +266,11 @@ export function loadSpellbook(
|
||||
workspaces: newWorkspaces,
|
||||
windows: newWindows,
|
||||
activeWorkspaceId: firstNewWorkspaceId || state.activeWorkspaceId,
|
||||
activeSpellbook: {
|
||||
id: spellbook.event?.id || uuidv4(), // Fallback to uuid if local
|
||||
slug: spellbook.slug,
|
||||
title: spellbook.title,
|
||||
pubkey: spellbook.event?.pubkey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,4 +95,10 @@ export interface GrimoireState {
|
||||
timeFormat: "12h" | "24h";
|
||||
};
|
||||
relayState?: GlobalRelayState;
|
||||
activeSpellbook?: {
|
||||
id: string; // event id or local uuid
|
||||
slug: string; // d-tag
|
||||
title: string;
|
||||
pubkey?: string; // owner
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user