1
0
Fork 0

feat(command-palette): register page-specific commands across UI

Wire CommandPalette/Provider into the frontend layout and add Cmd+K
hint in the SiteHeader. Register context-aware commands for banking,
helpdesk, mission RSVP, locker, shipments, and market pages.

Also removes the locker search's duplicate Cmd+K listener (now handled
globally), renames its button to 'Search Locker', and cleans up a
flappy page parenthetical.
This commit is contained in:
Jason Fraley 2026-08-18 02:05:36 -04:00
parent 4906fc8008
commit cb2029ea97
11 changed files with 196 additions and 38 deletions

View file

@ -52,7 +52,7 @@ export default async function FlappyPage() {
<h1 className="text-lg font-semibold">Flight Simulator</h1>
</div>
<p className="text-sm text-muted-foreground">
An unofficial side activity. (Definitely not Flappy Bird.) Earn 1 XP per 15 obstacles
An unofficial side activity. Earn 1 XP per 15 obstacles
passed in a single run.
</p>
</div>

View file

@ -14,6 +14,9 @@ import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner";
import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
// Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user,
@ -93,22 +96,36 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
<body className="h-full font-sans m-0 text-white">
{user ? (
<SidebarProvider>
<AppSidebar
user={{
displayName: user.payloadDisplayName ?? "Guest",
username: user.username ?? "Not logged in",
}}
xpLevel={xpLevel}
adminUrl={payloadConfig.routes.admin}
<KeyboardShortcutsProvider
userRoles={user.roles ?? []}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
showCallsign={user.preferences?.display?.showCallsign ?? false}
className="border-r"
/>
<SidebarInset>
<SiteHeader />
{children}
</SidebarInset>
>
<CommandPaletteProvider>
<AppSidebar
user={{
displayName: user.payloadDisplayName ?? "Guest",
username: user.username ?? "Not logged in",
}}
xpLevel={xpLevel}
adminUrl={payloadConfig.routes.admin}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
showCallsign={user.preferences?.display?.showCallsign ?? false}
className="border-r"
/>
<SidebarInset>
<SiteHeader />
{children}
</SidebarInset>
<CommandPalette
userRoles={user.roles ?? []}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
username={user.username}
/>
</CommandPaletteProvider>
</KeyboardShortcutsProvider>
</SidebarProvider>
) : (
<LandingPage />

View file

@ -1,12 +1,17 @@
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell";
import { Command } from "lucide-react";
export const SiteHeader = () => {
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<div className="flex items-center gap-1.5 rounded-md border border-border/50 bg-muted/50 px-2 py-1 text-xs text-muted-foreground select-none">
<Command className="h-3 w-3" />
<span>K</span>
</div>
<div className="ml-auto flex items-center">
<NotificationsBell />
</div>

View file

@ -1,14 +1,15 @@
"use client";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import type { BankAccount, BankTransaction, Resource } from "@/payload-types";
import { LandmarkIcon } from "lucide-react";
import { LandmarkIcon, PlusIcon } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AccountCard } from "@/components/frontend/banking/AccountCard";
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
import { WalletHero } from "@/components/frontend/banking/WalletHero";
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
import { currencyLabel, formatAmount } from "@/lib/banking/format";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
interface BankingOverviewProps {
accounts: BankAccount[];
@ -55,6 +56,19 @@ export function BankingOverview({
isManager,
factions,
}: BankingOverviewProps) {
const [createAccountOpen, setCreateAccountOpen] = useState(false);
usePageCommands([
{
id: "banking:create-account",
group: "Banking",
label: "Create Bank Account",
icon: PlusIcon,
keywords: ["new", "account", "bank"],
onSelect: () => setCreateAccountOpen(true),
},
]);
const treasury = accounts.filter((a) => a.accountType === "treasury");
const factionAccounts = accounts.filter((a) => a.accountType === "faction");
const personal = accounts.filter((a) => a.accountType === "personal");
@ -129,7 +143,12 @@ export function BankingOverview({
{visibleAccountsCount}
</span>
</div>
<CreateAccountDialog isManager={isManager} factions={factions} />
<CreateAccountDialog
isManager={isManager}
factions={factions}
open={createAccountOpen}
onOpenChange={setCreateAccountOpen}
/>
</div>
{visibleAccountsCount === 0 ? (

View file

@ -10,8 +10,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog";
import { TicketCard } from "@/components/frontend/helpdesk/TicketCard";
import { CLOSING_STATUSES, STATUS_STYLES, type TicketStatus } from "@/lib/tickets/ticketMeta";
import { InboxIcon, SearchIcon } from "lucide-react";
import { InboxIcon, PlusIcon, SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
@ -56,6 +57,18 @@ function isUnassignedTicket(ticket: Ticket): boolean {
export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) {
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
usePageCommands([
{
id: "helpdesk:create-ticket",
group: "Helpdesk",
label: "Create Support Ticket",
icon: PlusIcon,
keywords: ["help", "support", "new", "ticket"],
onSelect: () => setCreateOpen(true),
},
]);
const counts = useMemo<Record<TicketFilter, number>>(
() => ({
@ -132,7 +145,7 @@ export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }:
: "Keep an eye on your open requests."}
</p>
</div>
<CreateTicketDialog />
<CreateTicketDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">

View file

@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { setMissionAttendance } from "@/app/(frontend)/intelligence/missions/actions";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
type AttendanceResponse = "yes" | "no" | "tentative";
@ -80,6 +81,39 @@ export function MissionAttendance({
router.refresh();
}
const canRsvp = currentUserId !== null && !attendanceClosed;
usePageCommands(
canRsvp
? [
{
id: `mission:${missionId}:rsvp-yes`,
group: "Mission RSVP",
label: "RSVP — Yes",
icon: CheckIcon,
keywords: ["attend", "going", "yes", "rsvp"],
onSelect: () => void handleSet("yes"),
},
{
id: `mission:${missionId}:rsvp-tentative`,
group: "Mission RSVP",
label: "RSVP — Tentative",
icon: MinusIcon,
keywords: ["maybe", "tentative", "unsure", "rsvp"],
onSelect: () => void handleSet("tentative"),
},
{
id: `mission:${missionId}:rsvp-no`,
group: "Mission RSVP",
label: "RSVP — No",
icon: XIcon,
keywords: ["decline", "not going", "no", "rsvp"],
onSelect: () => void handleSet("no"),
},
]
: [],
);
return (
<Item variant="outline">
<ItemHeader>

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Crosshair, Layers, Package, Palette, SearchIcon } from "lucide-react";
import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
@ -53,17 +53,6 @@ interface LockerSearchProps {
export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const entries = useMemo(() => buildSearchEntries(items, loadouts), [items, loadouts]);
const groups = useMemo(() => {
@ -82,10 +71,7 @@ export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) {
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<SearchIcon className="size-3.5" />
Search
<kbd className="ml-1 inline-flex h-4 items-center gap-0.5 rounded border border-border bg-muted px-1 text-[10px] font-medium text-muted-foreground">
⌘K
</kbd>
Search Locker
</Button>
<CommandDialog open={open} onOpenChange={setOpen} title="Search Locker">

View file

@ -24,6 +24,8 @@ import {
rotateLockerItem,
splitLockerStack,
} from "@/app/(frontend)/locker/actions";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import { LayoutGridIcon } from "lucide-react";
interface LockerViewProps {
locker: LockerStorage;
@ -141,6 +143,17 @@ export function LockerView({
if (result.success) refresh();
}, [refresh]);
usePageCommands([
{
id: "locker:auto-arrange",
group: "Locker",
label: "Auto-Arrange Locker",
icon: LayoutGridIcon,
keywords: ["arrange", "organize", "sort", "grid"],
onSelect: () => void handleAutoArrange(),
},
]);
const handleAttachItem = useCallback(
async (weaponEntryId: string, attachmentEntryId: string) => {
const result = await attachItemFromGrid(weaponEntryId, attachmentEntryId);

View file

@ -4,6 +4,14 @@ import { useMemo, useState } from "react";
import type { Shipment } from "@/payload-types";
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
import { Button } from "@/components/ui/button";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import {
CheckCircleIcon,
ClockIcon,
ListFilterIcon,
TruckIcon,
XCircleIcon,
} from "lucide-react";
const statusFilters = [
{ label: "All", value: null },
@ -22,6 +30,49 @@ interface ShipmentsListProps {
export function ShipmentsList({ shipments }: ShipmentsListProps) {
const [filter, setFilter] = useState<string | null>(null);
usePageCommands([
{
id: "shipments:filter-all",
group: "Shipments",
label: "Filter — All Shipments",
icon: ListFilterIcon,
keywords: ["filter", "all", "shipments"],
onSelect: () => setFilter(null),
},
{
id: "shipments:filter-active",
group: "Shipments",
label: "Filter — Active Shipments",
icon: ClockIcon,
keywords: ["filter", "active", "in transit", "pending"],
onSelect: () => setFilter("active"),
},
{
id: "shipments:filter-completed",
group: "Shipments",
label: "Filter — Completed Shipments",
icon: CheckCircleIcon,
keywords: ["filter", "completed", "arrived", "done"],
onSelect: () => setFilter("completed"),
},
{
id: "shipments:filter-cancelled",
group: "Shipments",
label: "Filter — Cancelled Shipments",
icon: XCircleIcon,
keywords: ["filter", "cancelled", "failed"],
onSelect: () => setFilter("cancelled"),
},
{
id: "shipments:filter-stranded",
group: "Shipments",
label: "Filter — Stranded Shipments",
icon: TruckIcon,
keywords: ["filter", "stranded", "stuck"],
onSelect: () => setFilter("stranded"),
},
]);
const filtered = useMemo(() => {
if (!filter) return shipments;
if (filter === "active") {

View file

@ -15,7 +15,8 @@ import { RARITY_LABELS } from "@/lib/market";
import { ListingCard } from "@/components/frontend/market/ListingCard";
import { CreateListingDialog } from "@/components/frontend/market/CreateListingDialog";
import { NegotiationPanel } from "@/components/frontend/market/NegotiationPanel";
import { SearchIcon } from "lucide-react";
import { PlusIcon, SearchIcon } from "lucide-react";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
const ASSET_TYPE_OPTIONS: { value: string; label: string }[] = [
{ value: "weapon", label: "Weapon" },
@ -76,6 +77,18 @@ export function MarketView({
const [search, setSearch] = useState("");
const [rarity, setRarity] = useState("all");
const [assetType, setAssetType] = useState("all");
const [createListingOpen, setCreateListingOpen] = useState(false);
usePageCommands([
{
id: "market:create-listing",
group: "Market",
label: "Create Listing",
icon: PlusIcon,
keywords: ["sell", "list", "new", "market"],
onSelect: () => setCreateListingOpen(true),
},
]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
@ -138,7 +151,12 @@ export function MarketView({
</Select>
</div>
</div>
<CreateListingDialog locker={locker} currencyLabel={currencyLabel} />
<CreateListingDialog
locker={locker}
currencyLabel={currencyLabel}
open={createListingOpen}
onOpenChange={setCreateListingOpen}
/>
</div>
<section className="flex flex-col gap-3">

View file

@ -1288,6 +1288,7 @@ export interface User {
};
display?: {
showCallsign?: boolean | null;
leadOrActual?: ('lead' | 'actual') | null;
};
discord?: {
enabled?: boolean | null;
@ -2758,6 +2759,7 @@ export interface UsersSelect<T extends boolean = true> {
| T
| {
showCallsign?: T;
leadOrActual?: T;
};
discord?:
| T