Compare commits
6 commits
2f536a814b
...
4c3c3373d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c3c3373d3 | |||
| feccbc0ced | |||
| 72262d53b6 | |||
| cb2029ea97 | |||
| 4906fc8008 | |||
| 0fc61ebb47 |
40 changed files with 17176 additions and 126 deletions
1
TODO.md
1
TODO.md
|
|
@ -10,6 +10,7 @@
|
||||||
- [ ] Bidding and auctions – Allow sellers to list items as auctions with timed bidding instead of fixed-price buyouts.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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
|
## MVP – Priority Deadline by 8/15
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ export async function updatePreferences(input: {
|
||||||
mutedAll: boolean;
|
mutedAll: boolean;
|
||||||
mutedTypes: string[];
|
mutedTypes: string[];
|
||||||
showCallsign: boolean;
|
showCallsign: boolean;
|
||||||
|
leadOrActual: "lead" | "actual";
|
||||||
discordEnabled: boolean;
|
discordEnabled: boolean;
|
||||||
discordMutedTypes: string[];
|
discordMutedTypes: string[];
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
|
|
@ -215,7 +216,10 @@ export async function updatePreferences(input: {
|
||||||
data: {
|
data: {
|
||||||
preferences: {
|
preferences: {
|
||||||
notifications: { mutedAll: input.mutedAll, mutedTypes },
|
notifications: { mutedAll: input.mutedAll, mutedTypes },
|
||||||
display: { showCallsign: input.showCallsign },
|
display: {
|
||||||
|
showCallsign: input.showCallsign,
|
||||||
|
leadOrActual: input.leadOrActual,
|
||||||
|
},
|
||||||
discord: {
|
discord: {
|
||||||
enabled: input.discordEnabled,
|
enabled: input.discordEnabled,
|
||||||
mutedTypes: discordMutedTypes,
|
mutedTypes: discordMutedTypes,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export default async function AccountPage() {
|
||||||
mutedAll={notifications?.mutedAll ?? false}
|
mutedAll={notifications?.mutedAll ?? false}
|
||||||
mutedTypes={notifications?.mutedTypes ?? []}
|
mutedTypes={notifications?.mutedTypes ?? []}
|
||||||
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
|
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
|
||||||
|
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
|
||||||
discordEnabled={discord?.enabled ?? false}
|
discordEnabled={discord?.enabled ?? false}
|
||||||
discordMutedTypes={discord?.mutedTypes ?? []}
|
discordMutedTypes={discord?.mutedTypes ?? []}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,22 +96,36 @@ 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>
|
||||||
<AppSidebar
|
<KeyboardShortcutsProvider
|
||||||
user={{
|
userRoles={user.roles ?? []}
|
||||||
displayName: user.payloadDisplayName ?? "Guest",
|
|
||||||
username: user.username ?? "Not logged in",
|
|
||||||
}}
|
|
||||||
xpLevel={xpLevel}
|
|
||||||
adminUrl={payloadConfig.routes.admin}
|
|
||||||
hasIntelligence={isIntelligence}
|
hasIntelligence={isIntelligence}
|
||||||
hasLogistics={isLogistics}
|
hasLogistics={isLogistics}
|
||||||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
>
|
||||||
className="border-r"
|
<CommandPaletteProvider>
|
||||||
/>
|
<AppSidebar
|
||||||
<SidebarInset>
|
user={{
|
||||||
<SiteHeader />
|
displayName: user.payloadDisplayName ?? "Guest",
|
||||||
{children}
|
username: user.username ?? "Not logged in",
|
||||||
</SidebarInset>
|
}}
|
||||||
|
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>
|
</SidebarProvider>
|
||||||
) : (
|
) : (
|
||||||
<LandingPage />
|
<LandingPage />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import type { Media, Rank, User } from "@/payload-types";
|
import type { Media, Rank, User } from "@/payload-types";
|
||||||
import { UsersIcon } from "lucide-react";
|
import { UsersIcon } from "lucide-react";
|
||||||
import { RosterView } from "@/components/frontend/roster/RosterView";
|
import { RosterView } from "@/components/frontend/roster/RosterView";
|
||||||
|
|
@ -31,7 +32,9 @@ export type RosterAssignment = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RosterPage() {
|
export default async function RosterPage() {
|
||||||
|
const headers = await nextHeaders();
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
const { user: currentUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
const [profileRes, assignmentRes] = await Promise.all([
|
const [profileRes, assignmentRes] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
|
|
@ -128,7 +131,11 @@ export default async function RosterPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RosterView members={members} assignments={assignments} />
|
<RosterView
|
||||||
|
members={members}
|
||||||
|
assignments={assignments}
|
||||||
|
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,16 @@ export const Users: CollectionConfig = {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
defaultValue: false,
|
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",
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
261
src/components/command-palette/CommandPalette.tsx
Normal file
261
src/components/command-palette/CommandPalette.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/command-palette/CommandPaletteContext.tsx
Normal file
85
src/components/command-palette/CommandPaletteContext.tsx
Normal 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]);
|
||||||
|
}
|
||||||
27
src/components/command-palette/KeySequenceIndicator.tsx
Normal file
27
src/components/command-palette/KeySequenceIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/command-palette/KeyboardShortcutsProvider.tsx
Normal file
64
src/components/command-palette/KeyboardShortcutsProvider.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
96
src/components/command-palette/commands/actions.ts
Normal file
96
src/components/command-palette/commands/actions.ts
Normal 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",
|
||||||
|
},
|
||||||
|
];
|
||||||
158
src/components/command-palette/commands/navigation.ts
Normal file
158
src/components/command-palette/commands/navigation.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { Loader2Icon } from "lucide-react";
|
import { Loader2Icon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
||||||
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ interface PreferencesFormProps {
|
||||||
mutedAll: boolean;
|
mutedAll: boolean;
|
||||||
mutedTypes: string[];
|
mutedTypes: string[];
|
||||||
showCallsign: boolean;
|
showCallsign: boolean;
|
||||||
|
leadOrActual: "lead" | "actual";
|
||||||
discordEnabled: boolean;
|
discordEnabled: boolean;
|
||||||
discordMutedTypes: string[];
|
discordMutedTypes: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +22,7 @@ export function PreferencesForm({
|
||||||
mutedAll: initialMutedAll,
|
mutedAll: initialMutedAll,
|
||||||
mutedTypes: initialMutedTypes,
|
mutedTypes: initialMutedTypes,
|
||||||
showCallsign: initialShowCallsign,
|
showCallsign: initialShowCallsign,
|
||||||
|
leadOrActual: initialLeadOrActual,
|
||||||
discordEnabled: initialDiscordEnabled,
|
discordEnabled: initialDiscordEnabled,
|
||||||
discordMutedTypes: initialDiscordMutedTypes,
|
discordMutedTypes: initialDiscordMutedTypes,
|
||||||
}: PreferencesFormProps) {
|
}: PreferencesFormProps) {
|
||||||
|
|
@ -27,6 +30,7 @@ export function PreferencesForm({
|
||||||
const [mutedAll, setMutedAll] = useState(initialMutedAll);
|
const [mutedAll, setMutedAll] = useState(initialMutedAll);
|
||||||
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
|
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
|
||||||
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
|
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
|
||||||
|
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
|
||||||
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
|
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
|
||||||
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
|
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -56,6 +60,7 @@ export function PreferencesForm({
|
||||||
mutedAll,
|
mutedAll,
|
||||||
mutedTypes,
|
mutedTypes,
|
||||||
showCallsign,
|
showCallsign,
|
||||||
|
leadOrActual,
|
||||||
discordEnabled,
|
discordEnabled,
|
||||||
discordMutedTypes,
|
discordMutedTypes,
|
||||||
});
|
});
|
||||||
|
|
@ -105,6 +110,35 @@ export function PreferencesForm({
|
||||||
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
|
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
|
||||||
Show callsign (username) instead of display name
|
Show callsign (username) instead of display name
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,23 @@ const modeMeta: Record<
|
||||||
interface BankTransactionDialogProps {
|
interface BankTransactionDialogProps {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
otherAccounts: { id: number; name: string }[];
|
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 router = useRouter();
|
||||||
const [mode, setMode] = useState<Mode>("deposit");
|
const [mode, setMode] = useState<Mode>(initialMode);
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = externalOpen ?? internalOpen;
|
||||||
|
const setOpen = externalOnOpenChange ?? setInternalOpen;
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [toId, setToId] = useState("");
|
const [toId, setToId] = useState("");
|
||||||
const [memo, setMemo] = useState("");
|
const [memo, setMemo] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,22 @@ import { createBankAccount } from "@/app/(frontend)/logistics/banking/actions";
|
||||||
interface CreateAccountDialogProps {
|
interface CreateAccountDialogProps {
|
||||||
isManager: boolean;
|
isManager: boolean;
|
||||||
factions: { id: number; name: string }[];
|
factions: { id: number; name: string }[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountType = "treasury" | "faction" | "personal";
|
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 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 [name, setName] = useState("");
|
||||||
const [accountType, setAccountType] = useState<AccountType>("personal");
|
const [accountType, setAccountType] = useState<AccountType>("personal");
|
||||||
const [ownerFactionId, setOwnerFactionId] = useState("");
|
const [ownerFactionId, setOwnerFactionId] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import {
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
|
||||||
|
interface CreateTicketDialogProps {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function isTicketCategory(value: string): value is TicketCategory {
|
function isTicketCategory(value: string): value is TicketCategory {
|
||||||
return TICKET_CATEGORIES.some((option) => option.value === value);
|
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);
|
return TICKET_PRIORITIES.some((option) => option.value === value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateTicketDialog() {
|
export function CreateTicketDialog({
|
||||||
|
open: externalOpen,
|
||||||
|
onOpenChange: externalOnOpenChange,
|
||||||
|
}: CreateTicketDialogProps = {}) {
|
||||||
const router = useRouter();
|
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 [title, setTitle] = useState("");
|
||||||
const [category, setCategory] = useState<TicketCategory>("support_request");
|
const [category, setCategory] = useState<TicketCategory>("support_request");
|
||||||
const [priority, setPriority] = useState<TicketPriority>("medium");
|
const [priority, setPriority] = useState<TicketPriority>("medium");
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import { createMarketListing } from "@/app/(frontend)/logistics/market/actions";
|
||||||
interface CreateListingDialogProps {
|
interface CreateListingDialogProps {
|
||||||
locker: LockerStorage | null;
|
locker: LockerStorage | null;
|
||||||
currencyLabel: string | null;
|
currencyLabel: string | null;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SellableAsset {
|
interface SellableAsset {
|
||||||
|
|
@ -42,9 +44,16 @@ interface SellableAsset {
|
||||||
basePrice: number | null;
|
basePrice: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateListingDialog({ locker, currencyLabel }: CreateListingDialogProps) {
|
export function CreateListingDialog({
|
||||||
|
locker,
|
||||||
|
currencyLabel,
|
||||||
|
open: externalOpen,
|
||||||
|
onOpenChange: externalOnOpenChange,
|
||||||
|
}: CreateListingDialogProps) {
|
||||||
const router = useRouter();
|
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 [assetId, setAssetId] = useState("");
|
||||||
const [quantity, setQuantity] = useState("1");
|
const [quantity, setQuantity] = useState("1");
|
||||||
const [price, setPrice] = useState("");
|
const [price, setPrice] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn, pluralize } from "@/lib/utils";
|
import { cn, pluralize } from "@/lib/utils";
|
||||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
|
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
|
||||||
|
|
||||||
|
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = {
|
const TYPE_ORDER: Record<string, number> = {
|
||||||
division: 0,
|
division: 0,
|
||||||
squad: 1,
|
squad: 1,
|
||||||
|
|
@ -346,6 +349,9 @@ function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
||||||
// ─── Member Card ────────────────────────────────────────────────────
|
// ─── Member Card ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function MemberCard({ member }: { member: RosterMember }) {
|
function MemberCard({ member }: { member: RosterMember }) {
|
||||||
|
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||||
|
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${member.user.username}`}
|
href={`/profile/${member.user.username}`}
|
||||||
|
|
@ -367,7 +373,7 @@ function MemberCard({ member }: { member: RosterMember }) {
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{member.isLeader && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{member.assignmentName && (
|
{member.assignmentName && (
|
||||||
|
|
@ -385,43 +391,47 @@ function MemberCard({ member }: { member: RosterMember }) {
|
||||||
export function RosterView({
|
export function RosterView({
|
||||||
members,
|
members,
|
||||||
assignments,
|
assignments,
|
||||||
|
leadOrActual = "lead",
|
||||||
}: {
|
}: {
|
||||||
members: RosterMember[];
|
members: RosterMember[];
|
||||||
assignments: RosterAssignment[];
|
assignments: RosterAssignment[];
|
||||||
|
leadOrActual?: "lead" | "actual";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="org">
|
<RosterDisplayContext.Provider value={leadOrActual}>
|
||||||
<TabsList variant="line">
|
<Tabs defaultValue="org">
|
||||||
<TabsTrigger value="org">
|
<TabsList variant="line">
|
||||||
<NetworkIcon className="size-3" />
|
<TabsTrigger value="org">
|
||||||
Org Chart
|
<NetworkIcon className="size-3" />
|
||||||
</TabsTrigger>
|
Org Chart
|
||||||
<TabsTrigger value="assignment">
|
</TabsTrigger>
|
||||||
<Building2Icon className="size-3" />
|
<TabsTrigger value="assignment">
|
||||||
By Assignment
|
<Building2Icon className="size-3" />
|
||||||
</TabsTrigger>
|
By Assignment
|
||||||
<TabsTrigger value="rank">
|
</TabsTrigger>
|
||||||
<ShieldIcon className="size-3" />
|
<TabsTrigger value="rank">
|
||||||
By Rank
|
<ShieldIcon className="size-3" />
|
||||||
</TabsTrigger>
|
By Rank
|
||||||
<TabsTrigger value="alpha">
|
</TabsTrigger>
|
||||||
<ListIcon className="size-3" />
|
<TabsTrigger value="alpha">
|
||||||
A–Z
|
<ListIcon className="size-3" />
|
||||||
</TabsTrigger>
|
A–Z
|
||||||
</TabsList>
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="org">
|
<TabsContent value="org">
|
||||||
<OrgChartView assignments={assignments} members={members} />
|
<OrgChartView assignments={assignments} members={members} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="assignment">
|
<TabsContent value="assignment">
|
||||||
<ByAssignmentView assignments={assignments} members={members} />
|
<ByAssignmentView assignments={assignments} members={members} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="rank">
|
<TabsContent value="rank">
|
||||||
<ByRankView members={members} />
|
<ByRankView members={members} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="alpha">
|
<TabsContent value="alpha">
|
||||||
<AlphabeticalView members={members} />
|
<AlphabeticalView members={members} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</RosterDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
src/hooks/useKeyboardShortcuts.ts
Normal file
94
src/hooks/useKeyboardShortcuts.ts
Normal 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 };
|
||||||
|
}
|
||||||
15972
src/migrations/20260818_024510.json
Normal file
15972
src/migrations/20260818_024510.json
Normal file
File diff suppressed because it is too large
Load diff
65
src/migrations/20260818_024510.ts
Normal file
65
src/migrations/20260818_024510.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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 = [
|
export const migrations = [
|
||||||
/*{
|
|
||||||
up: migration_20251206_065831.up,
|
|
||||||
down: migration_20251206_065831.down,
|
|
||||||
name: '20251206_065831',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
up: migration_20251212_183607.up,
|
up: migration_20260818_024510.up,
|
||||||
down: migration_20251212_183607.down,
|
down: migration_20260818_024510.down,
|
||||||
name: '20251212_183607',
|
name: '20260818_024510'
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue