Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 60 additions & 6 deletions client/app/map/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import MapboxDraw from "@mapbox/mapbox-gl-draw";
Expand All @@ -14,10 +14,12 @@ import { InsertModelModal } from "@/components/InsertModelModal";
import { AssetManagerPanel } from "@/components/AssetManagerPanel";
import { Prompt3DGenerator } from "@/components/Prompt3DGenerator";
import { TransformGizmo } from "@/components/TransformGizmo";
import { SearchBar } from "@/components/SearchBar";
import { CommandPalette } from "@/components/CommandPalette";
import { CommandHelp } from "@/components/CommandHelp";
import { SearchResultPopup } from "@/components/SearchResultPopup";
import { MapControls } from "@/components/MapControls";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import type { CommandContext, InsertedModel as CommandInsertedModel } from "@/lib/commands";

interface SelectedBuilding {
id: string | number;
Expand Down Expand Up @@ -137,6 +139,7 @@ export default function MapPage() {
const [gizmoScreenPos, setGizmoScreenPos] = useState<{ x: number; y: number } | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [showCommandHelp, setShowCommandHelp] = useState(false);
const [searchResult, setSearchResult] = useState<{
intent: {
action: string;
Expand Down Expand Up @@ -858,6 +861,51 @@ export default function MapPage() {
});
}, [updateModelsSource]);

// Command palette context
const commandContext: CommandContext = useMemo(() => ({
map: map.current,
activeTool: activeTool || "select",
setActiveTool: (tool) => handleSetActiveTool(tool === "generate" ? null : tool as "select" | "draw" | "insert" | null),
insertedModels: insertedModels as unknown as CommandInsertedModel[],
setInsertedModels: setInsertedModels as unknown as React.Dispatch<React.SetStateAction<CommandInsertedModel[]>>,
selectedModelId,
setSelectedModelId,
setWeather,
setLightMode,
handleUndo,
handleRedo,
handleFlyToModel,
handleDeleteModel,
setShowInsertModal: (show) => {
if (show) {
handleSetActiveTool("insert");
}
},
setIsGeneratorVisible: setShowPromptGenerator,
handleSearch,
setSearchQuery,
setShowLabels: (show) => {
map.current?.setConfigProperty("basemap", "showPlaceLabels", show);
},
setShowRoads: (show) => {
map.current?.setConfigProperty("basemap", "showRoadLabels", show);
},
setShowPOIs: (show) => {
map.current?.setConfigProperty("basemap", "showPointOfInterestLabels", show);
},
setShowHelp: setShowCommandHelp,
}), [
activeTool,
insertedModels,
selectedModelId,
handleUndo,
handleRedo,
handleFlyToModel,
handleDeleteModel,
handleSearch,
handleSetActiveTool,
]);

// Keyboard shortcut for deleting selected model
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -1581,20 +1629,26 @@ export default function MapPage() {
/>
)}

{/* Search Bar */}
{/* Command Palette (Smart Search) */}
<div
data-search-container
className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 w-[500px] rounded-2xl bg-black/40 backdrop-blur-md border border-white/10 shadow-xl px-4 py-2"
>
<SearchBar
<CommandPalette
value={searchQuery}
onChange={setSearchQuery}
onSearch={handleSearch}
context={commandContext}
isLoading={isSearching}
placeholder="Search anywhere... (e.g., 'take me to Paris', 'tallest building')"
placeholder="Type / for commands, or search naturally..."
/>
</div>


{/* Command Help Modal */}
{showCommandHelp && (
<CommandHelp onClose={() => setShowCommandHelp(false)} />
)}

<div ref={mapContainer} className="h-full w-full" />
</div>
);
Expand Down
85 changes: 85 additions & 0 deletions client/components/CommandDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";

import { useMemo } from "react";
import { type FuzzyMatch, CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/commands";
import { CommandItem } from "./CommandItem";

interface CommandDropdownProps {
matches: FuzzyMatch[];
selectedIndex: number;
onSelect: (match: FuzzyMatch) => void;
onHover: (index: number) => void;
}

export function CommandDropdown({
matches,
selectedIndex,
onSelect,
onHover,
}: CommandDropdownProps) {
// Group matches by category
const grouped = useMemo(() => {
const groups = new Map<string, { match: FuzzyMatch; globalIndex: number }[]>();

matches.forEach((match, globalIndex) => {
const category = match.command.category;
const existing = groups.get(category) || [];
existing.push({ match, globalIndex });
groups.set(category, existing);
});

// Sort by category order
const sortedGroups: {
category: string;
label: string;
items: { match: FuzzyMatch; globalIndex: number }[];
}[] = [];

for (const category of CATEGORY_ORDER) {
const items = groups.get(category);
if (items && items.length > 0) {
sortedGroups.push({
category,
label: CATEGORY_LABELS[category],
items,
});
}
}

return sortedGroups;
}, [matches]);

if (matches.length === 0) {
return null;
}

return (
<div className="absolute bottom-full left-0 right-0 mb-2 max-h-[400px] overflow-y-auto rounded-xl bg-black/90 backdrop-blur-md border border-white/10 shadow-xl z-50 animate-[fadeIn_0.15s_ease-out_forwards]">
{grouped.map((group) => (
<div key={group.category}>
{/* Category Header */}
<div className="sticky top-0 px-3 py-2 text-[10px] text-white/40 uppercase tracking-wider font-medium bg-black/80 backdrop-blur-sm border-b border-white/5">
{group.label}
</div>

{/* Commands in Category */}
{group.items.map(({ match, globalIndex }) => (
<CommandItem
key={match.command.id}
match={match}
isSelected={globalIndex === selectedIndex}
onSelect={() => onSelect(match)}
onHover={() => onHover(globalIndex)}
/>
))}
</div>
))}

{/* Hint at bottom */}
<div className="px-3 py-2 text-[10px] text-white/30 border-t border-white/5 flex items-center justify-between">
<span>Type without / for AI search</span>
<span>/help for all commands</span>
</div>
</div>
);
}
141 changes: 141 additions & 0 deletions client/components/CommandHelp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client";

import { useEffect } from "react";
import { Cross2Icon } from "@radix-ui/react-icons";
import { commands, CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/commands";

interface CommandHelpProps {
onClose: () => void;
}

export function CommandHelp({ onClose }: CommandHelpProps) {
// Group commands by category
const grouped = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
commands: commands.filter((c) => c.category === category),
})).filter((g) => g.commands.length > 0);

// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out_forwards]"
onClick={onClose}
>
<div
className="w-[700px] max-h-[80vh] overflow-hidden rounded-2xl bg-black/70 backdrop-blur-md border border-white/10 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 flex items-center justify-between p-4 border-b border-white/10 bg-black/50 backdrop-blur-sm">
<div>
<h2 className="text-white font-semibold text-lg">Command Palette</h2>
<p className="text-white/50 text-sm">Type / in the search bar to use commands</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-all"
>
<Cross2Icon className="w-5 h-5" />
</button>
</div>

{/* Content */}
<div className="overflow-y-auto max-h-[calc(80vh-80px)] p-4 space-y-6">
{grouped.map((group) => (
<div key={group.category}>
<h3 className="text-white/60 text-xs uppercase tracking-wider font-medium mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-white/20" />
{group.label}
</h3>

<div className="grid grid-cols-1 gap-1">
{group.commands.map((cmd) => {
const Icon = cmd.icon;
return (
<div
key={cmd.id}
className="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-white/5 transition-all group"
>
{/* Icon */}
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 group-hover:bg-white/10 transition-all">
<Icon className="w-4 h-4 text-white/50" />
</div>

{/* Trigger */}
<code className="w-28 text-sm text-white/80 font-mono">
{cmd.trigger}
</code>

{/* Arguments */}
<span className="w-24 text-xs text-white/40">
{cmd.arguments
? cmd.arguments.map((a) =>
a.required ? `<${a.name}>` : `[${a.name}]`
).join(" ")
: "—"}
</span>

{/* Description */}
<span className="flex-1 text-sm text-white/50">
{cmd.description}
</span>

{/* Aliases */}
{cmd.aliases.length > 0 && (
<span className="text-xs text-white/30">
{cmd.aliases.join(", ")}
</span>
)}
</div>
);
})}
</div>
</div>
))}

{/* Tips Section */}
<div className="mt-6 pt-6 border-t border-white/10">
<h3 className="text-white/60 text-xs uppercase tracking-wider font-medium mb-3">
Tips
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2 text-white/50">
<kbd className="px-2 py-1 bg-white/10 rounded text-xs text-white/70">↑ ↓</kbd>
<span>Navigate commands</span>
</div>
<div className="flex items-center gap-2 text-white/50">
<kbd className="px-2 py-1 bg-white/10 rounded text-xs text-white/70">Tab</kbd>
<span>Autocomplete command</span>
</div>
<div className="flex items-center gap-2 text-white/50">
<kbd className="px-2 py-1 bg-white/10 rounded text-xs text-white/70">Enter</kbd>
<span>Execute command</span>
</div>
<div className="flex items-center gap-2 text-white/50">
<kbd className="px-2 py-1 bg-white/10 rounded text-xs text-white/70">Esc</kbd>
<span>Close dropdown</span>
</div>
</div>

<p className="mt-4 text-white/40 text-sm">
Type without <code className="text-white/60">/</code> to use natural language AI search
(e.g., &quot;take me to Tokyo&quot; or &quot;find the tallest building&quot;)
</p>
</div>
</div>
</div>
</div>
);
}
Loading