1
0
Fork 0

Compare commits

...

6 commits

Author SHA1 Message Date
4c3c3373d3 feat(roster): implement leadOrActual display preference
- Fetch current user's leadOrActual preference in roster page via payload.auth()
- Add RosterDisplayContext to avoid prop drilling through org chart components
- MemberCard shows 'Lead' or 'Actual' based on user preference
- Add preference radio buttons to PreferencesForm under Display section
- Update updatePreferences server action to save leadOrActual
- Add TODO for restyling radio buttons later
2026-08-18 02:20:58 -04:00
feccbc0ced chore(migrations): archive superseded migrations and add consolidated migration
Move old migrations (20260109 through 20260817) to _archived/ and add a
single consolidated migration (20260818_024510). Update the migrations
index to reference only the new migration.
2026-08-18 02:05:56 -04:00
72262d53b6 feat(users): add leadOrActual roster display preference
Adds a 'leadOrActual' select preference to the display group so users
can choose between 'Lead' or 'Actual' when reviewing the unit roster.
2026-08-18 02:05:41 -04:00
cb2029ea97 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.
2026-08-18 02:05:36 -04:00
4906fc8008 refactor(ui): make dialogs controlled via open/onOpenChange props
Support external open state management so parent components and the
command palette can trigger these dialogs programmatically.
2026-08-18 02:05:20 -04:00
0fc61ebb47 feat(command-palette): add command palette with Cmd+K keyboard shortcuts 2026-08-18 02:05:00 -04:00
40 changed files with 17176 additions and 126 deletions

View file

@ -10,6 +10,7 @@
- [ ] Bidding and auctions – Allow sellers to list items as auctions with timed bidding instead of fixed-price buyouts.
- [ ] Marketplace fees and market price drift – Percentage listing/sale fees and price fluctuations that tend toward each asset's resting `baseBuyPrice`/`baseSellPrice` over time.
- [ ] Implement command palette – A feature that provides users with a list of available commands and allows them to execute them with a single keystroke or gesture. The command palette should be accessible from a designated keybind or gesture and should display a list of relevant commands based on the user's current context.
- [ ] Restyle roster leader label radio buttons in PreferencesForm – The native HTML radio buttons work but look out of place; replace with shadcn RadioGroup or SegmentedControl for visual consistency.
## MVP – Priority Deadline by 8/15

View file

@ -195,6 +195,7 @@ export async function updatePreferences(input: {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
leadOrActual: "lead" | "actual";
discordEnabled: boolean;
discordMutedTypes: string[];
}): Promise<ActionResult> {
@ -215,7 +216,10 @@ export async function updatePreferences(input: {
data: {
preferences: {
notifications: { mutedAll: input.mutedAll, mutedTypes },
display: { showCallsign: input.showCallsign },
display: {
showCallsign: input.showCallsign,
leadOrActual: input.leadOrActual,
},
discord: {
enabled: input.discordEnabled,
mutedTypes: discordMutedTypes,

View file

@ -91,6 +91,7 @@ export default async function AccountPage() {
mutedAll={notifications?.mutedAll ?? false}
mutedTypes={notifications?.mutedTypes ?? []}
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
discordEnabled={discord?.enabled ?? false}
discordMutedTypes={discord?.mutedTypes ?? []}
/>

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,5 +1,6 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import type { Media, Rank, User } from "@/payload-types";
import { UsersIcon } from "lucide-react";
import { RosterView } from "@/components/frontend/roster/RosterView";
@ -31,7 +32,9 @@ export type RosterAssignment = {
};
export default async function RosterPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user: currentUser } = await payload.auth({ headers, canSetHeaders: false });
const [profileRes, assignmentRes] = await Promise.all([
payload.find({
@ -128,7 +131,11 @@ export default async function RosterPage() {
</p>
</div>
<RosterView members={members} assignments={assignments} />
<RosterView
members={members}
assignments={assignments}
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
/>
</div>
);
}

View file

@ -210,6 +210,16 @@ export const Users: CollectionConfig = {
type: "checkbox",
defaultValue: false,
},
{
name: "leadOrActual",
label: 'Use "Lead" or "Actual" when reviewing the unit roster',
type: "select",
options: [
{ label: "Lead", value: "lead" },
{ label: "Actual", value: "actual" },
],
defaultValue: "lead",
}
],
},
{

View file

@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { User } from "lucide-react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { navigationCommands, type NavigationCommand } from "./commands/navigation";
import { actionCommands, type ActionCommand } from "./commands/actions";
import {
CommandPaletteContext,
type ContextualCommand,
} from "./CommandPaletteContext";
import { markAllNotificationsRead } from "@/app/(frontend)/notifications/actions";
import { autoArrangeLocker } from "@/app/(frontend)/locker/actions";
interface CommandPaletteProps {
userRoles: string[];
hasIntelligence: boolean;
hasLogistics: boolean;
username?: string;
}
export function CommandPalette({
userRoles,
hasIntelligence,
hasLogistics,
username,
}: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState<string | null>(null);
const contextualCommands = React.useContext(CommandPaletteContext)?.commands ?? [];
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const runCommand = React.useCallback((command: () => unknown) => {
setOpen(false);
command();
}, []);
const handleCallback = React.useCallback(
async (actionKey: string) => {
setLoading(actionKey);
try {
switch (actionKey) {
case "markAllRead": {
const result = await markAllNotificationsRead();
if (result.success) {
toast.success("All notifications marked as read");
} else {
toast.error(result.error ?? "Failed to mark notifications");
}
break;
}
case "autoArrangeLocker": {
const result = await autoArrangeLocker();
if (result.success) {
toast.success("Locker arranged");
} else {
toast.error(result.error ?? "Failed to arrange locker");
}
break;
}
case "refreshPage": {
router.refresh();
toast.success("Page refreshed");
break;
}
case "logout": {
await fetch("/api/users/logout", { method: "POST" });
window.location.href = "/login";
break;
}
}
} finally {
setLoading(null);
}
},
[router],
);
const filteredNavCommands = React.useMemo<NavigationCommand[]>(() => {
const base = navigationCommands.filter((cmd) => {
if (
cmd.roles &&
!cmd.roles.some((r) => userRoles.includes(r) || userRoles.includes("developer"))
) {
return false;
}
if (cmd.requires === "intelligence" && !hasIntelligence && !userRoles.includes("developer")) {
return false;
}
if (cmd.requires === "logistics" && !hasLogistics && !userRoles.includes("developer")) {
return false;
}
return true;
});
if (username) {
return [
{
id: "nav:my-profile",
label: "My Profile",
icon: User,
href: `/profile/${username}`,
keywords: ["awards", "rank", "progression", "service record"],
},
...base,
];
}
return base;
}, [userRoles, hasIntelligence, hasLogistics, username]);
const filteredActionCommands = React.useMemo<ActionCommand[]>(() => {
return actionCommands.filter((cmd) => {
if (
cmd.roles &&
!cmd.roles.some((r) => userRoles.includes(r) || userRoles.includes("developer"))
) {
return false;
}
if (cmd.requires === "intelligence" && !hasIntelligence && !userRoles.includes("developer")) {
return false;
}
if (cmd.requires === "logistics" && !hasLogistics && !userRoles.includes("developer")) {
return false;
}
return true;
});
}, [userRoles, hasIntelligence, hasLogistics]);
const groupedContextual = React.useMemo(() => {
const groups = new Map<string, ContextualCommand[]>();
for (const cmd of contextualCommands) {
const existing = groups.get(cmd.group) ?? [];
existing.push(cmd);
groups.set(cmd.group, existing);
}
return groups;
}, [contextualCommands]);
return (
<CommandDialog open={open} onOpenChange={setOpen} title="Command Palette">
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-4">
<p className="text-sm text-muted-foreground">No results found.</p>
<p className="text-xs text-muted-foreground/70">
Try searching for "dashboard", "locker", or "notifications"
</p>
</div>
</CommandEmpty>
<CommandGroup heading="Navigation">
{filteredNavCommands.map((cmd) => (
<CommandItem
key={cmd.id}
value={cmd.label}
keywords={cmd.keywords}
onSelect={() => runCommand(() => router.push(cmd.href))}
>
<cmd.icon className="mr-2 h-4 w-4" />
{cmd.label}
{cmd.shortcut && <CommandShortcut>{cmd.shortcut}</CommandShortcut>}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
{filteredActionCommands.map((cmd) => (
<CommandItem
key={cmd.id}
value={cmd.label}
keywords={cmd.keywords}
disabled={loading !== null}
onSelect={() => {
if (cmd.actionType === "navigate" && cmd.href) {
const href = cmd.href;
runCommand(() => router.push(href));
} else if (cmd.actionType === "callback" && cmd.actionKey) {
const key = cmd.actionKey;
runCommand(() => handleCallback(key));
}
}}
>
<cmd.icon className={`mr-2 h-4 w-4 ${loading === cmd.actionKey ? "animate-spin" : ""}`} />
{cmd.label}
</CommandItem>
))}
</CommandGroup>
{groupedContextual.size > 0 && (
<>
<CommandSeparator />
{Array.from(groupedContextual.entries()).map(([group, cmds]) => (
<CommandGroup key={group} heading={group}>
{cmds.map((cmd) => (
<CommandItem
key={cmd.id}
value={cmd.label}
keywords={cmd.keywords}
onSelect={() => runCommand(cmd.onSelect)}
>
{cmd.icon && <cmd.icon className="mr-2 h-4 w-4" />}
{cmd.label}
{cmd.shortcut && <CommandShortcut>{cmd.shortcut}</CommandShortcut>}
</CommandItem>
))}
</CommandGroup>
))}
</>
)}
</CommandList>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<kbd className="rounded border bg-muted px-1">↑↓</kbd>
<span>navigate</span>
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border bg-muted px-1">↵</kbd>
<span>select</span>
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border bg-muted px-1">esc</kbd>
<span>close</span>
</span>
</div>
<span>⌘K to toggle</span>
</div>
</CommandDialog>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import * as React from "react";
export interface ContextualCommand {
id: string;
group: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
shortcut?: string;
keywords?: string[];
onSelect: () => void;
}
interface CommandPaletteAPI {
registerCommand: (entry: ContextualCommand) => void;
unregisterCommand: (id: string) => void;
commands: ContextualCommand[];
}
const CommandPaletteContext = React.createContext<CommandPaletteAPI | null>(null);
export { CommandPaletteContext };
export function useCommandPalette() {
const ctx = React.useContext(CommandPaletteContext);
if (!ctx) {
throw new Error("useCommandPalette must be used within CommandPaletteProvider");
}
return ctx;
}
export function CommandPaletteProvider({ children }: { children: React.ReactNode }) {
const commandsRef = React.useRef<Map<string, ContextualCommand>>(new Map());
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
const registerCommand = React.useCallback((entry: ContextualCommand) => {
commandsRef.current.set(entry.id, entry);
forceUpdate();
}, []);
const unregisterCommand = React.useCallback((id: string) => {
commandsRef.current.delete(id);
forceUpdate();
}, []);
const commands = React.useMemo(
() => Array.from(commandsRef.current.values()),
// forceUpdate triggers re-render; recompute on each render via ref identity
// eslint-disable-next-line react-hooks/exhaustive-deps
[commandsRef, forceUpdate],
);
const value = React.useMemo<CommandPaletteAPI>(
() => ({ registerCommand, unregisterCommand, commands }),
[registerCommand, unregisterCommand, commands],
);
return (
<CommandPaletteContext.Provider value={value}>{children}</CommandPaletteContext.Provider>
);
}
/** Register a set of commands for the current page. Unregisters on unmount. */
export function usePageCommands(commands: ContextualCommand[]) {
const { registerCommand, unregisterCommand } = useCommandPalette();
// Keep the latest commands in a ref so the effect doesn't re-run on every
// render when the parent recreates the array. The effect syncs the registry
// to the ref contents on each change.
const commandsRef = React.useRef(commands);
commandsRef.current = commands;
React.useEffect(() => {
const current = commandsRef.current;
for (const cmd of current) {
registerCommand(cmd);
}
return () => {
for (const cmd of current) {
unregisterCommand(cmd.id);
}
};
}, [registerCommand, unregisterCommand]);
}

View file

@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface KeySequenceIndicatorProps {
pendingKey: string | null;
className?: string;
}
export function KeySequenceIndicator({ pendingKey, className }: KeySequenceIndicatorProps) {
if (!pendingKey) return null;
return (
<div
className={cn(
"fixed bottom-4 left-1/2 -translate-x-1/2 z-50",
"flex items-center gap-2 rounded-lg border bg-background/95 px-4 py-2 shadow-lg",
"animate-in fade-in slide-in-from-bottom-2 duration-200",
className,
)}
>
<kbd className="rounded border bg-muted px-2 py-0.5 text-sm font-medium">{pendingKey}</kbd>
<span className="text-sm text-muted-foreground">...</span>
</div>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { useKeyboardShortcuts, type ShortcutMap } from "@/hooks/useKeyboardShortcuts";
import { KeySequenceIndicator } from "./KeySequenceIndicator";
import * as React from "react";
interface KeyboardShortcutsProviderProps {
children: React.ReactNode;
userRoles?: string[];
hasIntelligence?: boolean;
hasLogistics?: boolean;
}
const BASE_SHORTCUTS: ShortcutMap = {
"g d": "/",
"g r": "/roster",
"g h": "/helpdesk",
"g l": "/locker",
"g n": "/notifications",
};
const INTELLIGENCE_SHORTCUTS: ShortcutMap = {
"g c": "/intelligence/campaigns",
"g m": "/intelligence/missions",
};
const LOGISTICS_SHORTCUTS: ShortcutMap = {
"g s": "/logistics",
"g t": "/logistics/shipments",
"g b": "/logistics/banking",
"g k": "/logistics/market",
};
const ADMIN_SHORTCUTS: ShortcutMap = {
"g a": "/admin",
};
export function KeyboardShortcutsProvider({
children,
userRoles = [],
hasIntelligence = false,
hasLogistics = false,
}: KeyboardShortcutsProviderProps) {
const shortcuts = React.useMemo(() => {
const isAdmin = userRoles.includes("admin") || userRoles.includes("developer");
const isDev = userRoles.includes("developer");
return {
...BASE_SHORTCUTS,
...(hasIntelligence || isDev ? INTELLIGENCE_SHORTCUTS : {}),
...(hasLogistics || isDev ? LOGISTICS_SHORTCUTS : {}),
...(isAdmin ? ADMIN_SHORTCUTS : {}),
};
}, [userRoles, hasIntelligence, hasLogistics]);
const { pendingKey } = useKeyboardShortcuts(shortcuts);
return (
<>
{children}
<KeySequenceIndicator pendingKey={pendingKey} />
</>
);
}

View file

@ -0,0 +1,96 @@
import {
UserPlus,
Bell,
Backpack,
LogOut,
PlaneTakeoff,
User,
RefreshCw,
Shield,
} from "lucide-react";
export interface ActionCommand {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
keywords?: string[];
/** Action type determines how onSelect is handled */
actionType: "navigate" | "callback";
/** For navigate actions */
href?: string;
/** For callback actions - the action key that CommandPalette maps to a handler */
actionKey?: string;
/** If set, only shown for these roles */
roles?: string[];
/** If set, only shown when user has this qualification */
requires?: "intelligence" | "logistics";
}
export const actionCommands: ActionCommand[] = [
{
id: "action:flight-simulator",
label: "Flight Simulator",
icon: PlaneTakeoff,
keywords: ["flappy", "game", "minigame", "fly"],
actionType: "navigate",
href: "/flappy",
},
{
id: "action:view-notifications",
label: "View Notifications",
icon: Bell,
keywords: ["alerts", "messages", "inbox"],
actionType: "navigate",
href: "/notifications",
},
{
id: "action:create-ticket",
label: "Create Support Ticket",
icon: UserPlus,
keywords: ["help", "support", "report"],
actionType: "navigate",
href: "/helpdesk",
},
{
id: "action:admin-panel",
label: "Open Admin Panel",
icon: Shield,
keywords: ["cms", "manage", "backend"],
actionType: "navigate",
href: "/admin",
roles: ["admin", "developer"],
},
{
id: "action:mark-all-read",
label: "Mark All Notifications Read",
icon: Bell,
keywords: ["clear", "dismiss", "notifications"],
actionType: "callback",
actionKey: "markAllRead",
},
{
id: "action:auto-arrange-locker",
label: "Auto-Arrange Locker",
icon: Backpack,
keywords: ["sort", "organize", "inventory"],
actionType: "callback",
actionKey: "autoArrangeLocker",
},
{
id: "action:refresh-page",
label: "Refresh Page",
icon: RefreshCw,
keywords: ["reload", "update"],
actionType: "callback",
actionKey: "refreshPage",
},
{
id: "action:logout",
label: "Log Out",
icon: LogOut,
keywords: ["signout", "exit"],
actionType: "callback",
actionKey: "logout",
},
];

View file

@ -0,0 +1,158 @@
import {
Bolt,
Users,
LifeBuoy,
Crosshair,
Warehouse,
Truck,
Fuel,
Map,
Backpack,
UserCog,
Bell,
Shield,
} from "lucide-react";
export interface NavigationCommand {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
href: string;
keywords?: string[];
shortcut?: string;
requires?: "intelligence" | "logistics";
roles?: string[];
}
export const navigationCommands: NavigationCommand[] = [
{ id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" },
{ id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" },
{
id: "nav:helpdesk",
label: "Helpdesk",
icon: LifeBuoy,
href: "/helpdesk",
keywords: ["support", "tickets"],
shortcut: "G H",
},
{
id: "nav:campaigns",
label: "Campaigns",
icon: Crosshair,
href: "/intelligence/campaigns",
keywords: ["operations"],
requires: "intelligence",
shortcut: "G C",
},
{
id: "nav:missions",
label: "Missions",
icon: Crosshair,
href: "/intelligence/missions",
keywords: ["sorties", "ops"],
requires: "intelligence",
shortcut: "G M",
},
{
id: "nav:factions",
label: "Factions",
icon: Crosshair,
href: "/intelligence/factions",
keywords: ["groups", "teams"],
requires: "intelligence",
},
{
id: "nav:structures",
label: "Structures",
icon: Warehouse,
href: "/logistics",
keywords: ["buildings", "storage"],
requires: "logistics",
shortcut: "G S",
},
{
id: "nav:resources",
label: "Resources",
icon: Fuel,
href: "/logistics/resources",
keywords: ["materials", "supplies"],
requires: "logistics",
},
{
id: "nav:vehicles",
label: "Vehicles",
icon: Truck,
href: "/logistics/vehicles",
keywords: ["cars", "trucks"],
requires: "logistics",
},
{
id: "nav:shipments",
label: "Shipments",
icon: Truck,
href: "/logistics/shipments",
keywords: ["shipping", "delivery"],
requires: "logistics",
shortcut: "G T",
},
{
id: "nav:deployed-vehicles",
label: "Deployed Vehicles",
icon: Map,
href: "/logistics/game-vehicles",
keywords: ["active", "field"],
requires: "logistics",
},
{
id: "nav:banking",
label: "Banking",
icon: UserCog,
href: "/logistics/banking",
keywords: ["finance", "accounts", "money"],
requires: "logistics",
shortcut: "G B",
},
{
id: "nav:market",
label: "Market",
icon: UserCog,
href: "/logistics/market",
keywords: ["trading", "flea market", "buy", "sell"],
requires: "logistics",
shortcut: "G K",
},
{
id: "nav:locker",
label: "Locker",
icon: Backpack,
href: "/locker",
keywords: ["inventory", "gear", "equipment"],
shortcut: "G L",
},
{
id: "nav:account",
label: "Account",
icon: UserCog,
href: "/account",
keywords: ["settings", "profile", "password"],
},
{
id: "nav:notifications",
label: "Notifications",
icon: Bell,
href: "/notifications",
keywords: ["alerts", "messages"],
},
{
id: "nav:admin",
label: "Admin Panel",
icon: Shield,
href: "/admin",
keywords: ["cms", "manage"],
roles: ["admin", "developer"],
},
];

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

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
import { updatePreferences } from "@/app/(frontend)/account/actions";
@ -12,6 +13,7 @@ interface PreferencesFormProps {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
leadOrActual: "lead" | "actual";
discordEnabled: boolean;
discordMutedTypes: string[];
}
@ -20,6 +22,7 @@ export function PreferencesForm({
mutedAll: initialMutedAll,
mutedTypes: initialMutedTypes,
showCallsign: initialShowCallsign,
leadOrActual: initialLeadOrActual,
discordEnabled: initialDiscordEnabled,
discordMutedTypes: initialDiscordMutedTypes,
}: PreferencesFormProps) {
@ -27,6 +30,7 @@ export function PreferencesForm({
const [mutedAll, setMutedAll] = useState(initialMutedAll);
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
const [error, setError] = useState<string | null>(null);
@ -56,6 +60,7 @@ export function PreferencesForm({
mutedAll,
mutedTypes,
showCallsign,
leadOrActual,
discordEnabled,
discordMutedTypes,
});
@ -105,6 +110,35 @@ export function PreferencesForm({
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
Show callsign (username) instead of display name
</label>
<div className="flex flex-col gap-2 pl-6">
<Label className="text-sm text-muted-foreground">
Roster leader label
</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="lead"
checked={leadOrActual === "lead"}
onChange={() => setLeadOrActual("lead")}
className="accent-primary"
/>
Lead
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="actual"
checked={leadOrActual === "actual"}
onChange={() => setLeadOrActual("actual")}
className="accent-primary"
/>
Actual
</label>
</div>
</div>
</div>
<div className="flex flex-col gap-3">

View file

@ -57,12 +57,23 @@ const modeMeta: Record<
interface BankTransactionDialogProps {
accountId: number;
otherAccounts: { id: number; name: string }[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
initialMode?: Mode;
}
export function BankTransactionDialog({ accountId, otherAccounts }: BankTransactionDialogProps) {
export function BankTransactionDialog({
accountId,
otherAccounts,
open: externalOpen,
onOpenChange: externalOnOpenChange,
initialMode = "deposit",
}: BankTransactionDialogProps) {
const router = useRouter();
const [mode, setMode] = useState<Mode>("deposit");
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>(initialMode);
const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalOnOpenChange ?? setInternalOpen;
const [amount, setAmount] = useState("");
const [toId, setToId] = useState("");
const [memo, setMemo] = useState("");

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

@ -27,13 +27,22 @@ import { createBankAccount } from "@/app/(frontend)/logistics/banking/actions";
interface CreateAccountDialogProps {
isManager: boolean;
factions: { id: number; name: string }[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type AccountType = "treasury" | "faction" | "personal";
export function CreateAccountDialog({ isManager, factions }: CreateAccountDialogProps) {
export function CreateAccountDialog({
isManager,
factions,
open: externalOpen,
onOpenChange: externalOnOpenChange,
}: CreateAccountDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalOnOpenChange ?? setInternalOpen;
const [name, setName] = useState("");
const [accountType, setAccountType] = useState<AccountType>("personal");
const [ownerFactionId, setOwnerFactionId] = useState("");

View file

@ -34,6 +34,11 @@ import {
import { cn } from "@/lib/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface CreateTicketDialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function isTicketCategory(value: string): value is TicketCategory {
return TICKET_CATEGORIES.some((option) => option.value === value);
}
@ -42,9 +47,14 @@ function isTicketPriority(value: string): value is TicketPriority {
return TICKET_PRIORITIES.some((option) => option.value === value);
}
export function CreateTicketDialog() {
export function CreateTicketDialog({
open: externalOpen,
onOpenChange: externalOnOpenChange,
}: CreateTicketDialogProps = {}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalOnOpenChange ?? setInternalOpen;
const [title, setTitle] = useState("");
const [category, setCategory] = useState<TicketCategory>("support_request");
const [priority, setPriority] = useState<TicketPriority>("medium");

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

@ -31,6 +31,8 @@ import { createMarketListing } from "@/app/(frontend)/logistics/market/actions";
interface CreateListingDialogProps {
locker: LockerStorage | null;
currencyLabel: string | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface SellableAsset {
@ -42,9 +44,16 @@ interface SellableAsset {
basePrice: number | null;
}
export function CreateListingDialog({ locker, currencyLabel }: CreateListingDialogProps) {
export function CreateListingDialog({
locker,
currencyLabel,
open: externalOpen,
onOpenChange: externalOnOpenChange,
}: CreateListingDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalOnOpenChange ?? setInternalOpen;
const [assetId, setAssetId] = useState("");
const [quantity, setQuantity] = useState("1");
const [price, setPrice] = useState("");

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

@ -1,11 +1,14 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { cn, pluralize } from "@/lib/utils";
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
const TYPE_ORDER: Record<string, number> = {
division: 0,
squad: 1,
@ -346,6 +349,9 @@ function AlphabeticalView({ members }: { members: RosterMember[] }) {
// ─── Member Card ────────────────────────────────────────────────────
function MemberCard({ member }: { member: RosterMember }) {
const leadOrActual = React.useContext(RosterDisplayContext);
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
return (
<Link
href={`/profile/${member.user.username}`}
@ -367,7 +373,7 @@ function MemberCard({ member }: { member: RosterMember }) {
<div className="flex items-center gap-1.5 shrink-0">
{member.isLeader && (
<span className="text-[10px] uppercase tracking-wider text-amber-400 bg-amber-950/50 border border-amber-800/50 px-1.5 py-0.5 rounded font-semibold">
Lead
{leadLabel}
</span>
)}
{member.assignmentName && (
@ -385,43 +391,47 @@ function MemberCard({ member }: { member: RosterMember }) {
export function RosterView({
members,
assignments,
leadOrActual = "lead",
}: {
members: RosterMember[];
assignments: RosterAssignment[];
leadOrActual?: "lead" | "actual";
}) {
return (
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha">
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
</TabsList>
<RosterDisplayContext.Provider value={leadOrActual}>
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha">
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
</TabsList>
<TabsContent value="org">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">
<ByAssignmentView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="rank">
<ByRankView members={members} />
</TabsContent>
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
</Tabs>
<TabsContent value="org">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">
<ByAssignmentView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="rank">
<ByRankView members={members} />
</TabsContent>
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
</Tabs>
</RosterDisplayContext.Provider>
);
}

View file

@ -0,0 +1,94 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
export interface ShortcutMap {
[sequence: string]: string;
}
const SEQUENCE_TIMEOUT_MS = 800;
export function useKeyboardShortcuts(shortcuts: ShortcutMap) {
const router = useRouter();
const [pendingKey, setPendingKey] = React.useState<string | null>(null);
const pendingKeyRef = React.useRef<string | null>(null);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const shortcutsRef = React.useRef(shortcuts);
shortcutsRef.current = shortcuts;
const firstKeys = React.useMemo(() => {
const keys = new Set<string>();
for (const seq of Object.keys(shortcuts)) {
const first = seq.split(" ")[0];
if (first) keys.add(first);
}
return keys;
}, [shortcuts]);
const firstKeysRef = React.useRef(firstKeys);
firstKeysRef.current = firstKeys;
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
const key = e.key.toLowerCase();
if (key === "escape") {
pendingKeyRef.current = null;
setPendingKey(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
return;
}
if (pendingKeyRef.current === null) {
if (firstKeysRef.current.has(key)) {
pendingKeyRef.current = key;
setPendingKey(key);
timeoutRef.current = setTimeout(() => {
pendingKeyRef.current = null;
setPendingKey(null);
timeoutRef.current = null;
}, SEQUENCE_TIMEOUT_MS);
return;
}
} else {
const sequence = `${pendingKeyRef.current} ${key}`;
const target = shortcutsRef.current[sequence];
pendingKeyRef.current = null;
setPendingKey(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (target) {
e.preventDefault();
router.push(target);
return;
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [router]);
return { pendingKey };
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_plugin_ai_instructions_field_type" AS ENUM('text', 'textarea', 'upload', 'richText');
CREATE TYPE "public"."enum_plugin_ai_instructions_model_id" AS ENUM('unsloth-text');
CREATE TABLE "plugin_ai_instructions_images" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"image_id" integer
);
CREATE TABLE "plugin_ai_instructions" (
"id" serial PRIMARY KEY NOT NULL,
"schema_path" varchar,
"field_type" "enum_plugin_ai_instructions_field_type" DEFAULT 'text',
"relation_to" varchar,
"model_id" "enum_plugin_ai_instructions_model_id",
"disabled" boolean DEFAULT false,
"prompt" varchar,
"system" varchar DEFAULT 'INSTRUCTIONS:
You are a highly skilled and professional blog writer,
renowned for crafting engaging and well-organized articles.
When given a title, you meticulously create blogs that are not only
informative and accurate but also captivating and beautifully structured.',
"layout" varchar DEFAULT '[paragraph] - Write a concise introduction (2-3 sentences) that outlines the main topic.
[horizontalrule] - Insert a horizontal rule to separate the introduction from the main content.
[list] - Create a list with 3-5 items. Each list item should contain:
a. [heading] - A brief, descriptive heading (up to 5 words)
b. [paragraph] - A short explanation or elaboration (1-2 sentences)
[horizontalrule] - Insert another horizontal rule to separate the main content from the conclusion.
[paragraph] - Compose a brief conclusion (2-3 sentences) summarizing the key points.
[quote] - Include a relevant quote from a famous person, directly related to the topic. Format: "Quote text." - Author Name',
"unsloth_text_settings_model" varchar DEFAULT 'unsloth/gemma-4-E4B-it-qat-GGUF',
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "plugin_ai_instructions_id" integer;
ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "plugin_ai_instructions_images_order_idx" ON "plugin_ai_instructions_images" USING btree ("_order");
CREATE INDEX "plugin_ai_instructions_images_parent_id_idx" ON "plugin_ai_instructions_images" USING btree ("_parent_id");
CREATE INDEX "plugin_ai_instructions_images_image_idx" ON "plugin_ai_instructions_images" USING btree ("image_id");
CREATE UNIQUE INDEX "plugin_ai_instructions_schema_path_idx" ON "plugin_ai_instructions" USING btree ("schema_path");
CREATE INDEX "plugin_ai_instructions_updated_at_idx" ON "plugin_ai_instructions" USING btree ("updated_at");
CREATE INDEX "plugin_ai_instructions_created_at_idx" ON "plugin_ai_instructions" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk" FOREIGN KEY ("plugin_ai_instructions_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx" ON "payload_locked_documents_rels" USING btree ("plugin_ai_instructions_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "plugin_ai_instructions_images" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "plugin_ai_instructions" DISABLE ROW LEVEL SECURITY;
DROP TABLE "plugin_ai_instructions_images" CASCADE;
DROP TABLE "plugin_ai_instructions" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk";
DROP INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "plugin_ai_instructions_id";
DROP TYPE "public"."enum_plugin_ai_instructions_field_type";
DROP TYPE "public"."enum_plugin_ai_instructions_model_id";`)
}

View file

@ -1,49 +1,13 @@
import * as migration_20260817_053752 from "./20260817_053752";
import * as migration_20260109_065519 from './_archived/20260109_065519';
import * as migration_20260816_213455 from './_archived/20260816_213455';
import * as migration_20260816_220000 from './_archived/20260816_220000';
import * as migration_20260817_053752 from './_archived/20260817_053752';
import * as migration_20260818_024510 from './20260818_024510';
export const migrations = [
/*{
up: migration_20251206_065831.up,
down: migration_20251206_065831.down,
name: '20251206_065831',
},
{
up: migration_20251212_183607.up,
down: migration_20251212_183607.down,
name: '20251212_183607',
},
{
up: migration_20251219_061127.up,
down: migration_20251219_061127.down,
name: '20251219_061127',
},
{
up: migration_20251222_200320.up,
down: migration_20251222_200320.down,
name: '20251222_200320',
},
{
up: migration_20251223_055859.up,
down: migration_20251223_055859.down,
name: '20251223_055859',
},*/
// {
// up: migration_20260109_065519.up,
// down: migration_20260109_065519.down,
// name: "20260109_065519",
// },
// {
// up: migration_20260816_213455.up,
// down: migration_20260816_213455.down,
// name: "20260816_213455",
// },
// {
// up: migration_20260816_220000.up,
// down: migration_20260816_220000.down,
// name: "20260816_220000",
// },
{
up: migration_20260817_053752.up,
down: migration_20260817_053752.down,
name: "20260817_053752",
up: migration_20260818_024510.up,
down: migration_20260818_024510.down,
name: '20260818_024510'
},
];

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