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> <h1 className="text-lg font-semibold">Flight Simulator</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <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. passed in a single run.
</p> </p>
</div> </div>

View file

@ -14,6 +14,9 @@ import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime"; import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import { LandingPage } from "@/components/frontend/LandingPage"; import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner"; 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)/). // Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user, // The layout calls `getPayload()` at render time to resolve the current user,
@ -93,6 +96,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
<body className="h-full font-sans m-0 text-white"> <body className="h-full font-sans m-0 text-white">
{user ? ( {user ? (
<SidebarProvider> <SidebarProvider>
<KeyboardShortcutsProvider
userRoles={user.roles ?? []}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
>
<CommandPaletteProvider>
<AppSidebar <AppSidebar
user={{ user={{
displayName: user.payloadDisplayName ?? "Guest", displayName: user.payloadDisplayName ?? "Guest",
@ -109,6 +118,14 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
<SiteHeader /> <SiteHeader />
{children} {children}
</SidebarInset> </SidebarInset>
<CommandPalette
userRoles={user.roles ?? []}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
username={user.username}
/>
</CommandPaletteProvider>
</KeyboardShortcutsProvider>
</SidebarProvider> </SidebarProvider>
) : ( ) : (
<LandingPage /> <LandingPage />

View file

@ -1,12 +1,17 @@
import { SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell"; import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell";
import { Command } from "lucide-react";
export const SiteHeader = () => { export const SiteHeader = () => {
return ( return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4"> <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <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"> <div className="ml-auto flex items-center">
<NotificationsBell /> <NotificationsBell />
</div> </div>

View file

@ -1,14 +1,15 @@
"use client"; "use client";
import type { ReactNode } from "react"; import { useState, type ReactNode } from "react";
import type { BankAccount, BankTransaction, Resource } from "@/payload-types"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AccountCard } from "@/components/frontend/banking/AccountCard"; import { AccountCard } from "@/components/frontend/banking/AccountCard";
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog"; import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
import { WalletHero } from "@/components/frontend/banking/WalletHero"; import { WalletHero } from "@/components/frontend/banking/WalletHero";
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed"; import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
import { currencyLabel, formatAmount } from "@/lib/banking/format"; import { currencyLabel, formatAmount } from "@/lib/banking/format";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
interface BankingOverviewProps { interface BankingOverviewProps {
accounts: BankAccount[]; accounts: BankAccount[];
@ -55,6 +56,19 @@ export function BankingOverview({
isManager, isManager,
factions, factions,
}: BankingOverviewProps) { }: 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 treasury = accounts.filter((a) => a.accountType === "treasury");
const factionAccounts = accounts.filter((a) => a.accountType === "faction"); const factionAccounts = accounts.filter((a) => a.accountType === "faction");
const personal = accounts.filter((a) => a.accountType === "personal"); const personal = accounts.filter((a) => a.accountType === "personal");
@ -129,7 +143,12 @@ export function BankingOverview({
{visibleAccountsCount} {visibleAccountsCount}
</span> </span>
</div> </div>
<CreateAccountDialog isManager={isManager} factions={factions} /> <CreateAccountDialog
isManager={isManager}
factions={factions}
open={createAccountOpen}
onOpenChange={setCreateAccountOpen}
/>
</div> </div>
{visibleAccountsCount === 0 ? ( {visibleAccountsCount === 0 ? (

View file

@ -10,8 +10,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog"; import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog";
import { TicketCard } from "@/components/frontend/helpdesk/TicketCard"; import { TicketCard } from "@/components/frontend/helpdesk/TicketCard";
import { CLOSING_STATUSES, STATUS_STYLES, type TicketStatus } from "@/lib/tickets/ticketMeta"; 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 { cn } from "@/lib/utils";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned"; type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
@ -56,6 +57,18 @@ function isUnassignedTicket(ticket: Ticket): boolean {
export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) { export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) {
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all"); const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
const [search, setSearch] = useState(""); 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>>( const counts = useMemo<Record<TicketFilter, number>>(
() => ({ () => ({
@ -132,7 +145,7 @@ export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }:
: "Keep an eye on your open requests."} : "Keep an eye on your open requests."}
</p> </p>
</div> </div>
<CreateTicketDialog /> <CreateTicketDialog open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <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 { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { setMissionAttendance } from "@/app/(frontend)/intelligence/missions/actions"; import { setMissionAttendance } from "@/app/(frontend)/intelligence/missions/actions";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
type AttendanceResponse = "yes" | "no" | "tentative"; type AttendanceResponse = "yes" | "no" | "tentative";
@ -80,6 +81,39 @@ export function MissionAttendance({
router.refresh(); 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 ( return (
<Item variant="outline"> <Item variant="outline">
<ItemHeader> <ItemHeader>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Crosshair, Layers, Package, Palette, SearchIcon } from "lucide-react"; import { Crosshair, Layers, Package, Palette, SearchIcon } from "lucide-react";
import type { Asset, Loadout } from "@/payload-types"; import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -53,17 +53,6 @@ interface LockerSearchProps {
export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) { export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) {
const [open, setOpen] = useState(false); 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 entries = useMemo(() => buildSearchEntries(items, loadouts), [items, loadouts]);
const groups = useMemo(() => { const groups = useMemo(() => {
@ -82,10 +71,7 @@ export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) {
<> <>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}> <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<SearchIcon className="size-3.5" /> <SearchIcon className="size-3.5" />
Search Search Locker
<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>
</Button> </Button>
<CommandDialog open={open} onOpenChange={setOpen} title="Search Locker"> <CommandDialog open={open} onOpenChange={setOpen} title="Search Locker">

View file

@ -24,6 +24,8 @@ import {
rotateLockerItem, rotateLockerItem,
splitLockerStack, splitLockerStack,
} from "@/app/(frontend)/locker/actions"; } from "@/app/(frontend)/locker/actions";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import { LayoutGridIcon } from "lucide-react";
interface LockerViewProps { interface LockerViewProps {
locker: LockerStorage; locker: LockerStorage;
@ -141,6 +143,17 @@ export function LockerView({
if (result.success) refresh(); if (result.success) refresh();
}, [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( const handleAttachItem = useCallback(
async (weaponEntryId: string, attachmentEntryId: string) => { async (weaponEntryId: string, attachmentEntryId: string) => {
const result = await attachItemFromGrid(weaponEntryId, attachmentEntryId); const result = await attachItemFromGrid(weaponEntryId, attachmentEntryId);

View file

@ -4,6 +4,14 @@ import { useMemo, useState } from "react";
import type { Shipment } from "@/payload-types"; import type { Shipment } from "@/payload-types";
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard"; import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import {
CheckCircleIcon,
ClockIcon,
ListFilterIcon,
TruckIcon,
XCircleIcon,
} from "lucide-react";
const statusFilters = [ const statusFilters = [
{ label: "All", value: null }, { label: "All", value: null },
@ -22,6 +30,49 @@ interface ShipmentsListProps {
export function ShipmentsList({ shipments }: ShipmentsListProps) { export function ShipmentsList({ shipments }: ShipmentsListProps) {
const [filter, setFilter] = useState<string | null>(null); 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(() => { const filtered = useMemo(() => {
if (!filter) return shipments; if (!filter) return shipments;
if (filter === "active") { if (filter === "active") {

View file

@ -15,7 +15,8 @@ import { RARITY_LABELS } from "@/lib/market";
import { ListingCard } from "@/components/frontend/market/ListingCard"; import { ListingCard } from "@/components/frontend/market/ListingCard";
import { CreateListingDialog } from "@/components/frontend/market/CreateListingDialog"; import { CreateListingDialog } from "@/components/frontend/market/CreateListingDialog";
import { NegotiationPanel } from "@/components/frontend/market/NegotiationPanel"; 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 }[] = [ const ASSET_TYPE_OPTIONS: { value: string; label: string }[] = [
{ value: "weapon", label: "Weapon" }, { value: "weapon", label: "Weapon" },
@ -76,6 +77,18 @@ export function MarketView({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [rarity, setRarity] = useState("all"); const [rarity, setRarity] = useState("all");
const [assetType, setAssetType] = 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 filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@ -138,7 +151,12 @@ export function MarketView({
</Select> </Select>
</div> </div>
</div> </div>
<CreateListingDialog locker={locker} currencyLabel={currencyLabel} /> <CreateListingDialog
locker={locker}
currencyLabel={currencyLabel}
open={createListingOpen}
onOpenChange={setCreateListingOpen}
/>
</div> </div>
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">

View file

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