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:
parent
4906fc8008
commit
cb2029ea97
11 changed files with 196 additions and 38 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,6 +96,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
<body className="h-full font-sans m-0 text-white">
|
||||
{user ? (
|
||||
<SidebarProvider>
|
||||
<KeyboardShortcutsProvider
|
||||
userRoles={user.roles ?? []}
|
||||
hasIntelligence={isIntelligence}
|
||||
hasLogistics={isLogistics}
|
||||
>
|
||||
<CommandPaletteProvider>
|
||||
<AppSidebar
|
||||
user={{
|
||||
displayName: user.payloadDisplayName ?? "Guest",
|
||||
|
|
@ -109,6 +118,14 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
<SiteHeader />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette
|
||||
userRoles={user.roles ?? []}
|
||||
hasIntelligence={isIntelligence}
|
||||
hasLogistics={isLogistics}
|
||||
username={user.username}
|
||||
/>
|
||||
</CommandPaletteProvider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</SidebarProvider>
|
||||
) : (
|
||||
<LandingPage />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue