mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
- Add useIsMobile hook for viewport detection - TabBar: larger height (48px), disable reorder on mobile, hide workspace numbers - Header buttons: larger touch targets for SpellbookDropdown, UserMenu, LayoutControls - Window toolbar: larger buttons (40px) on mobile - Mosaic dividers: wider (12px) on mobile for easier dragging - CommandLauncher: larger items, footer text, hide kbd hints on mobile - Editor suggestions: responsive widths, min-height 44px touch targets - EventFooter: larger kind/relay buttons on mobile - CodeCopyButton: larger padding and icon on mobile - MembersDropdown: larger trigger and list items on mobile All changes use mobile-first Tailwind (base styles for mobile, md: for desktop) to meet Apple HIG 44px minimum touch target recommendation. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
272 lines
9.1 KiB
TypeScript
272 lines
9.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Command } from "cmdk";
|
|
import { useAtom } from "jotai";
|
|
import { useLiveQuery } from "dexie-react-hooks";
|
|
import { useNavigate, useLocation } from "react-router";
|
|
import db from "@/services/db";
|
|
import { useGrimoire } from "@/core/state";
|
|
import { manPages } from "@/types/man";
|
|
import { parseCommandInput, executeCommandParser } from "@/lib/command-parser";
|
|
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
|
import "./command-launcher.css";
|
|
|
|
/** Check if current path doesn't have the window system (should navigate to / when launching commands) */
|
|
function isNonDashboardRoute(pathname: string): boolean {
|
|
// /run route - pop-out command page
|
|
if (pathname.startsWith("/run")) return true;
|
|
|
|
// NIP-19 preview routes are single-segment paths starting with npub1, note1, nevent1, naddr1
|
|
const segment = pathname.slice(1); // Remove leading /
|
|
if (segment.includes("/")) return false; // Multi-segment paths are not NIP-19 previews
|
|
return (
|
|
segment.startsWith("npub1") ||
|
|
segment.startsWith("note1") ||
|
|
segment.startsWith("nevent1") ||
|
|
segment.startsWith("naddr1")
|
|
);
|
|
}
|
|
|
|
interface CommandLauncherProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export default function CommandLauncher({
|
|
open,
|
|
onOpenChange,
|
|
}: CommandLauncherProps) {
|
|
const [input, setInput] = useState("");
|
|
const [editMode, setEditMode] = useAtom(commandLauncherEditModeAtom);
|
|
const { state, addWindow, updateWindow } = useGrimoire();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
// Fetch spells with aliases
|
|
const aliasedSpells =
|
|
useLiveQuery(() =>
|
|
db.spells
|
|
.toArray()
|
|
.then((spells) =>
|
|
spells.filter((s) => s.alias !== undefined && s.alias !== ""),
|
|
),
|
|
) || [];
|
|
|
|
// Prefill input when entering edit mode
|
|
useEffect(() => {
|
|
if (open && editMode) {
|
|
setInput(editMode.initialCommand);
|
|
} else if (!open) {
|
|
// Clear input and edit mode when dialog closes
|
|
setInput("");
|
|
setEditMode(null);
|
|
}
|
|
}, [open, editMode, setEditMode]);
|
|
|
|
// Parse input into command and arguments
|
|
const parsed = parseCommandInput(input);
|
|
const { commandName } = parsed;
|
|
|
|
// Check if it's a spell alias
|
|
const activeSpell = aliasedSpells.find(
|
|
(s) => s.alias?.toLowerCase() === commandName.toLowerCase(),
|
|
);
|
|
|
|
// Re-parse if it's a spell
|
|
const effectiveParsed = activeSpell
|
|
? parseCommandInput(
|
|
activeSpell.command +
|
|
(input.trim().includes(" ")
|
|
? " " + input.trim().split(/\s+/).slice(1).join(" ")
|
|
: ""),
|
|
)
|
|
: parsed;
|
|
|
|
const recognizedCommand = effectiveParsed.command;
|
|
|
|
// Filter commands by partial match on command name only
|
|
const filteredCommands = [
|
|
...Object.entries(manPages),
|
|
...aliasedSpells.map((s) => [
|
|
s.alias!,
|
|
{
|
|
name: s.alias!,
|
|
synopsis: s.alias!,
|
|
description: s.name || s.description || "",
|
|
category: "Spells",
|
|
appId: "req",
|
|
spellCommand: s.command,
|
|
} as any,
|
|
]),
|
|
].filter(([name]) => name.toLowerCase().includes(commandName.toLowerCase()));
|
|
|
|
// Execute command (async to support async argParsers)
|
|
const executeCommand = async () => {
|
|
if (!recognizedCommand) return;
|
|
|
|
// Execute argParser and get props/title
|
|
const result = await executeCommandParser(
|
|
effectiveParsed,
|
|
state.activeAccount?.pubkey,
|
|
);
|
|
|
|
if (result.error || !result.props) {
|
|
console.error("Failed to parse command:", result.error);
|
|
return;
|
|
}
|
|
|
|
// Edit mode: update existing window
|
|
if (editMode) {
|
|
updateWindow(editMode.windowId, {
|
|
props: result.props,
|
|
commandString: activeSpell ? effectiveParsed.fullInput : input.trim(),
|
|
appId: recognizedCommand.appId,
|
|
customTitle: result.globalFlags?.windowProps?.title,
|
|
});
|
|
setEditMode(null); // Clear edit mode
|
|
} else {
|
|
// If on a non-dashboard route (no window system), navigate to dashboard first
|
|
// The window will appear after navigation since state persists
|
|
if (isNonDashboardRoute(location.pathname)) {
|
|
navigate("/");
|
|
}
|
|
|
|
// Normal mode: create new window
|
|
addWindow(
|
|
recognizedCommand.appId,
|
|
result.props,
|
|
activeSpell ? effectiveParsed.fullInput : input.trim(),
|
|
result.globalFlags?.windowProps?.title,
|
|
activeSpell?.id,
|
|
);
|
|
}
|
|
|
|
onOpenChange(false);
|
|
};
|
|
|
|
// Handle item selection (populate input, don't execute)
|
|
const handleSelect = (selectedCommand: string) => {
|
|
setInput(selectedCommand + " ");
|
|
};
|
|
|
|
// Handle Enter key
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
executeCommand();
|
|
}
|
|
};
|
|
|
|
// Define category order: Nostr first, then Spells, then Documentation, then System
|
|
const categoryOrder = ["Nostr", "Spells", "Documentation", "System"];
|
|
const categories = Array.from(
|
|
new Set(filteredCommands.map(([_, cmd]) => cmd.category)),
|
|
).sort((a, b) => {
|
|
const indexA = categoryOrder.indexOf(a as string);
|
|
const indexB = categoryOrder.indexOf(b as string);
|
|
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
|
|
});
|
|
|
|
// Dynamic placeholder
|
|
const placeholder = recognizedCommand
|
|
? activeSpell
|
|
? activeSpell.command
|
|
: recognizedCommand.synopsis
|
|
: "Type a command...";
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="grimoire-command-launcher p-0">
|
|
<VisuallyHidden>
|
|
<DialogTitle>Command Launcher</DialogTitle>
|
|
</VisuallyHidden>
|
|
<Command
|
|
label="Command Launcher"
|
|
className="grimoire-command-content"
|
|
shouldFilter={false}
|
|
>
|
|
<div className="command-launcher-wrapper">
|
|
<Command.Input
|
|
value={input}
|
|
onValueChange={setInput}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
className="command-input"
|
|
autoFocus
|
|
/>
|
|
|
|
<Command.List className="command-list">
|
|
<Command.Empty className="command-empty">
|
|
{commandName
|
|
? `No command found: ${commandName}`
|
|
: "Start typing..."}
|
|
</Command.Empty>
|
|
|
|
{categories.map((category) => (
|
|
<Command.Group
|
|
key={category}
|
|
heading={category}
|
|
className="command-group"
|
|
>
|
|
{filteredCommands
|
|
.filter(([_, cmd]) => cmd.category === category)
|
|
.map(([name, cmd]) => {
|
|
const isExactMatch = name === commandName;
|
|
return (
|
|
<Command.Item
|
|
key={name}
|
|
value={name}
|
|
onSelect={() => handleSelect(name)}
|
|
className="command-item"
|
|
data-exact-match={isExactMatch}
|
|
>
|
|
<div className="command-item-content">
|
|
<div className="command-item-name">
|
|
<span className="command-name">{name}</span>
|
|
{cmd.synopsis !== name && (
|
|
<span className="command-args">
|
|
{cmd.synopsis.replace(name, "").trim()}
|
|
</span>
|
|
)}
|
|
{isExactMatch && (
|
|
<span className="command-match-indicator">
|
|
✓
|
|
</span>
|
|
)}
|
|
</div>
|
|
{cmd.description && (
|
|
<div className="command-item-description">
|
|
{cmd.description.split(".")[0]}
|
|
</div>
|
|
)}
|
|
{cmd.spellCommand && (
|
|
<div className="text-xs md:text-[10px] opacity-50 font-mono truncate mt-0.5">
|
|
{cmd.spellCommand}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Command.Item>
|
|
);
|
|
})}
|
|
</Command.Group>
|
|
))}
|
|
</Command.List>
|
|
|
|
<div className="command-footer">
|
|
<div className="hidden md:block">
|
|
<kbd>↑↓</kbd> navigate
|
|
<kbd>↵</kbd> execute
|
|
<kbd>esc</kbd> close
|
|
</div>
|
|
{recognizedCommand && (
|
|
<div className="command-footer-status">Ready to execute</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Command>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|