Merge branch 'feat/tarkov-locker-ui'
# Conflicts: # TODO.md # src/components/frontend/eod/MinesweeperBoard.tsx # src/migrations/index.ts
This commit is contained in:
commit
b9999fceff
23 changed files with 1634 additions and 497 deletions
16
DESIGN.md
16
DESIGN.md
|
|
@ -67,6 +67,22 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
|||
- **Motion**: no section-level motion.
|
||||
- **Layout**: vertical stack.
|
||||
|
||||
### Locker inventory surface
|
||||
|
||||
- **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it.
|
||||
- **Palette**: Use the existing semantic neutral tokens: `bg-background` for the page, `bg-card`/`bg-muted` for framed regions, `border-border` for structure, `text-foreground` and `text-muted-foreground` for hierarchy, and `bg-primary`/`ring-ring` for active selection and primary actions. Amber utility accents are reserved for existing equipment/skin meaning; they do not become a second surface palette.
|
||||
- **Typography**: Keep Geist for interface copy and Geist Mono with `tabular-nums` for coordinates, counts, dimensions, and other inventory metadata. Compact labels use the existing `text-xs` level, never ad-hoc display sizes.
|
||||
- **Primitives**:
|
||||
- **`locker-shell`**: one responsive page region with a compact control band and a single-column fallback below `xl`.
|
||||
- **`inventory-frame`**: a strong rectangular border around the existing two-dimensional grid. The grid may scroll only inside this bounded region when its footprint exceeds the available inline size; the page itself must not gain a horizontal scrollbar.
|
||||
- **`selection-rail`**: selected-item context and actions sit beside the inventory at `xl` and later, then become an inline section below the grid at narrower widths. It remains readable when no item is selected.
|
||||
- **`metadata-strip`**: a compact cluster for occupancy, dimensions, and drag/drop guidance, using 4px spacing and semantic muted text.
|
||||
- **`wardrobe-slot-board`**: a body-ordered rectangular board: head/face spans the top, torso and pack occupy the middle, and weapons/attachments plus sidearm finish the lower row. The primary weapon owns an indented attachment subgroup for optic, muzzle, and bipod slots. Each slot is a labeled semantic button with distinct filled and empty states; the board is driven by `WARDROBE_SLOT_LAYOUT` and never relies on a character image as its control surface.
|
||||
- **States**: default, hover, focus-visible, selected, search-highlight, valid drop, invalid drop, attachment drop, empty, error, disabled, and reduced-motion. Selected items use a visible semantic ring; placement feedback stays inside the grid cells; empty and error states retain the existing user/manager-specific copy.
|
||||
- **Accessibility**: Tabs remain native shadcn tab semantics. Every icon-only button has an accessible label, every meaningful thumbnail keeps descriptive alt text, draggable items retain dnd-kit keyboard attributes, and all actions keep visible `focus-visible` rings. The responsive rail must not reorder focus away from the selected item context.
|
||||
- **Motion**: Preserve the existing dnd-kit activation threshold and absolute-position/ResizeObserver mechanics. Interaction polish is limited to existing shadcn color transitions, transform/opacity drag feedback, and the existing search highlight; `prefers-reduced-motion` disables highlight animation and nonessential transitions. No decorative looping motion is introduced.
|
||||
- **Responsive behavior**: At 375px the shell becomes one readable column with no viewport-level horizontal overflow. Only the `inventory-frame` may expose bounded two-dimensional scrolling when required by the configured grid; the selection rail follows the grid inline, while the tab/search control cluster wraps before it can overflow. The inventory and selection rail use the side-by-side layout only at `xl` and above so the grid remains dominant and rail labels remain readable. The wardrobe slot board keeps its body order at `lg` and collapses regions to one readable column below that threshold.
|
||||
|
||||
### Mission card
|
||||
|
||||
- **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"generate:version-info": "bun run src/scripts/generateVersionInfo.ts",
|
||||
"import:arma": "bun run src/tools/import/importArmaLibrary.ts",
|
||||
"version:bump": "bun run src/scripts/bumpVersion.ts",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export async function updatePreferences(input: {
|
|||
mutedTypes: string[];
|
||||
showCallsign: boolean;
|
||||
showVersionOverlay: boolean;
|
||||
showBackgroundTexture: boolean;
|
||||
leadOrActual: "lead" | "actual";
|
||||
discordEnabled: boolean;
|
||||
discordMutedTypes: string[];
|
||||
|
|
@ -221,7 +222,7 @@ export async function updatePreferences(input: {
|
|||
(MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t),
|
||||
);
|
||||
|
||||
await payload.update({
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
data: {
|
||||
|
|
@ -230,6 +231,7 @@ export async function updatePreferences(input: {
|
|||
display: {
|
||||
showCallsign: input.showCallsign,
|
||||
showVersionOverlay: input.showVersionOverlay,
|
||||
showBackgroundTexture: input.showBackgroundTexture,
|
||||
leadOrActual: input.leadOrActual,
|
||||
xpDisplayMini: input.xpDisplayMini,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,24 +81,25 @@ export default async function AccountPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preferences</CardTitle>
|
||||
<CardDescription>Control in-app notifications and display options.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PreferencesForm
|
||||
mutedAll={notifications?.mutedAll ?? false}
|
||||
mutedTypes={notifications?.mutedTypes ?? []}
|
||||
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
|
||||
showVersionOverlay={fullUser.preferences?.display?.showVersionOverlay ?? true}
|
||||
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
|
||||
discordEnabled={discord?.enabled ?? false}
|
||||
discordMutedTypes={discord?.mutedTypes ?? []}
|
||||
xpDisplayMini={fullUser.preferences?.display?.xpDisplayMini ?? false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preferences</CardTitle>
|
||||
<CardDescription>Control in-app notifications and display options.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PreferencesForm
|
||||
mutedAll={notifications?.mutedAll ?? false}
|
||||
mutedTypes={notifications?.mutedTypes ?? []}
|
||||
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
|
||||
showVersionOverlay={fullUser.preferences?.display?.showVersionOverlay ?? true}
|
||||
showBackgroundTexture={fullUser.preferences?.display?.showBackgroundTexture ?? true}
|
||||
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
|
||||
discordEnabled={discord?.enabled ?? false}
|
||||
discordMutedTypes={discord?.mutedTypes ?? []}
|
||||
xpDisplayMini={fullUser.preferences?.display?.xpDisplayMini ?? false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -139,7 +139,13 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
preferences={user.preferences}
|
||||
className="border-r"
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SidebarInset
|
||||
className={
|
||||
user.preferences?.display?.showBackgroundTexture !== false
|
||||
? "app-background-texture"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SiteHeader />
|
||||
{impersonating && <ImpersonationBanner />}
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap');
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap");
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
|
@ -13,80 +13,79 @@
|
|||
*/
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans: var(--font-sans);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
|
||||
--font-mono: var(--font-mono);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -94,74 +93,73 @@
|
|||
*/
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--font-sans: "Geist", sans-serif;
|
||||
|
||||
--font-sans: 'Geist', sans-serif;
|
||||
|
||||
--font-mono: 'Geist Mono', monospace;
|
||||
--font-mono: "Geist Mono", monospace;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -169,68 +167,67 @@
|
|||
*/
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -238,13 +235,13 @@
|
|||
*/
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -253,127 +250,166 @@
|
|||
|
||||
/* Top-down ORBAT-style tree connectors */
|
||||
.org-tree-child {
|
||||
position: relative;
|
||||
padding-top: 1rem;
|
||||
position: relative;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.org-tree-child::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
height: 1rem;
|
||||
border-left: 2px solid var(--color-border);
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
height: 1rem;
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.org-tree-child::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 2px solid var(--color-border);
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.org-tree-child:first-child::after {
|
||||
left: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.org-tree-child:last-child::after {
|
||||
right: 50%;
|
||||
right: 50%;
|
||||
}
|
||||
|
||||
.org-tree-child:only-child::after {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Locker search-result highlight */
|
||||
@keyframes locker-search-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(251 191 36 / 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgb(251 191 36 / 0);
|
||||
}
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(251 191 36 / 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgb(251 191 36 / 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xp-gain {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(0.35rem);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-1.1rem);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(0.35rem);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-1.1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.locker-search-highlight {
|
||||
animation: locker-search-pulse 1.1s ease-in-out infinite;
|
||||
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
|
||||
animation: locker-search-pulse 1.1s ease-in-out infinite;
|
||||
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
|
||||
}
|
||||
|
||||
.app-background-texture {
|
||||
background-image: repeating-linear-gradient(
|
||||
135deg,
|
||||
rgb(255 255 255 / 0.018) 0 1px,
|
||||
transparent 1px 7px
|
||||
);
|
||||
}
|
||||
|
||||
.inventory-frame {
|
||||
background-color: rgb(7 10 12 / 0.78);
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.018) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.018) 1px, transparent 1px),
|
||||
radial-gradient(circle at 50% 0%, rgb(120 150 145 / 0.09), transparent 55%);
|
||||
/*background-size:
|
||||
48px 48px,
|
||||
48px 48px,
|
||||
100% 100%;*/
|
||||
box-shadow:
|
||||
inset 0 1px rgb(255 255 255 / 0.04),
|
||||
0 12px 35px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.locker-search-highlight {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@property --medal-border-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes border-spin-animate {
|
||||
from {
|
||||
--medal-border-angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--medal-border-angle: 360deg;
|
||||
}
|
||||
from {
|
||||
--medal-border-angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--medal-border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.medal-card {
|
||||
--medal-border-angle: 0deg;
|
||||
background: rgb(193 161 79 / 0.28);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--medal-border-angle: 0deg;
|
||||
background: rgb(193 161 79 / 0.28);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.medal-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.125rem;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(var(--background), var(--background)) padding-box, repeating-conic-gradient(from var(--medal-border-angle), transparent 0deg 150deg, peru 159deg, rgb(253 224 71) 168deg, white 178deg, transparent 178deg) border-box;
|
||||
animation: border-spin-animate 10s linear infinite;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -0.125rem;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
border: 2px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--background), var(--background)) padding-box,
|
||||
repeating-conic-gradient(
|
||||
from var(--medal-border-angle),
|
||||
transparent 0deg 150deg,
|
||||
peru 159deg,
|
||||
rgb(253 224 71) 168deg,
|
||||
white 178deg,
|
||||
transparent 178deg
|
||||
)
|
||||
border-box;
|
||||
animation: border-spin-animate 10s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes border-spin-animate {
|
||||
from {
|
||||
--medal-border-angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--medal-border-angle: 360deg;
|
||||
}
|
||||
from {
|
||||
--medal-border-angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--medal-border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.medal-card * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.medal-card::after {
|
||||
content: '';
|
||||
background: radial-gradient(circle, transparent, rgb(253 224 71 / 0.1));
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
animation: pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
content: "";
|
||||
background: radial-gradient(circle, transparent, rgb(253 224 71 / 0.1));
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
animation: pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.award-xp-high {
|
||||
|
|
@ -400,30 +436,30 @@
|
|||
|
||||
/* Scrollbars — styled to match the dark frontend theme */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklch, var(--foreground) 20%, transparent) transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklch, var(--foreground) 20%, transparent) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(in oklch, var(--foreground) 20%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
background-color: color-mix(in oklch, var(--foreground) 20%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in oklch, var(--foreground) 32%, transparent);
|
||||
background-color: color-mix(in oklch, var(--foreground) 32%, transparent);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,41 +269,47 @@ export const Users: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "display",
|
||||
label: "Display",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "showCallsign",
|
||||
label: "Show callsign (username) instead of display name in the sidebar",
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "showVersionOverlay",
|
||||
label: "Show version overlay at top of page",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: "xpDisplayMini",
|
||||
label: "Use mini mode for XP display",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "display",
|
||||
label: "Display",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "showCallsign",
|
||||
label: "Show callsign (username) instead of display name in the sidebar",
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "showVersionOverlay",
|
||||
label: "Show version overlay at top of page",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: "xpDisplayMini",
|
||||
label: "Use mini mode for XP display",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "showBackgroundTexture",
|
||||
label: "Show diagonal background texture",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "discord",
|
||||
label: "Discord",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface PreferencesFormProps {
|
|||
readonly mutedTypes: readonly string[];
|
||||
readonly showCallsign: boolean;
|
||||
readonly showVersionOverlay: boolean;
|
||||
readonly showBackgroundTexture: boolean;
|
||||
readonly leadOrActual: "lead" | "actual";
|
||||
readonly discordEnabled: boolean;
|
||||
readonly discordMutedTypes: readonly string[];
|
||||
|
|
@ -28,6 +29,7 @@ export function PreferencesForm({
|
|||
mutedTypes: initialMutedTypes,
|
||||
showCallsign: initialShowCallsign,
|
||||
showVersionOverlay: initialShowVersionOverlay,
|
||||
showBackgroundTexture: initialShowBackgroundTexture,
|
||||
leadOrActual: initialLeadOrActual,
|
||||
discordEnabled: initialDiscordEnabled,
|
||||
discordMutedTypes: initialDiscordMutedTypes,
|
||||
|
|
@ -38,6 +40,7 @@ export function PreferencesForm({
|
|||
const [mutedTypes, setMutedTypes] = useState<string[]>(() => [...initialMutedTypes]);
|
||||
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
|
||||
const [showVersionOverlay, setShowVersionOverlay] = useState(initialShowVersionOverlay);
|
||||
const [showBackgroundTexture, setShowBackgroundTexture] = useState(initialShowBackgroundTexture);
|
||||
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
|
||||
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
|
||||
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(() => [
|
||||
|
|
@ -72,6 +75,7 @@ export function PreferencesForm({
|
|||
mutedTypes,
|
||||
showCallsign,
|
||||
showVersionOverlay,
|
||||
showBackgroundTexture,
|
||||
leadOrActual,
|
||||
discordEnabled,
|
||||
discordMutedTypes,
|
||||
|
|
@ -104,10 +108,7 @@ export function PreferencesForm({
|
|||
<CardDescription>Choose what appears in the app shell and roster views.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Label
|
||||
htmlFor="showCallsign"
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<Label htmlFor="showCallsign" className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Show callsign instead of display name</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
|
|
@ -120,9 +121,12 @@ export function PreferencesForm({
|
|||
onCheckedChange={setShowCallsign}
|
||||
aria-label="Show callsign instead of display name"
|
||||
/>
|
||||
</Label>
|
||||
</Label>
|
||||
|
||||
<Label htmlFor="xpDisplayMini" className="flex items-center justify-between gap-4 text-sm">
|
||||
<Label
|
||||
htmlFor="xpDisplayMini"
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Use mini mode for XP display</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
|
|
@ -137,6 +141,24 @@ export function PreferencesForm({
|
|||
/>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
htmlFor="showBackgroundTexture"
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Show diagonal background texture</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Adds the subtle tactical line pattern behind page content.
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
id="showBackgroundTexture"
|
||||
checked={showBackgroundTexture}
|
||||
onCheckedChange={setShowBackgroundTexture}
|
||||
aria-label="Show diagonal background texture"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">Roster leader label</span>
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export function AppSidebar({
|
|||
mutedTypes: preferences?.notifications?.mutedTypes ?? [],
|
||||
showCallsign: preferences?.display?.showCallsign ?? false,
|
||||
showVersionOverlay: preferences?.display?.showVersionOverlay ?? true,
|
||||
showBackgroundTexture: preferences?.display?.showBackgroundTexture ?? true,
|
||||
leadOrActual: preferences?.display?.leadOrActual ?? "lead",
|
||||
discordEnabled: preferences?.discord?.enabled ?? false,
|
||||
discordMutedTypes: preferences?.discord?.mutedTypes ?? [],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
|
||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { EodLeaderboard } from "./EodLeaderboard";
|
||||
import { MinesweeperBoard } from "./MinesweeperBoard";
|
||||
|
||||
|
|
@ -16,11 +17,25 @@ export function EodArena({
|
|||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<EodDifficulty>("training");
|
||||
const { isMobile, setOpen, setOpenMobile } = useSidebar();
|
||||
const activeTeams = useRealtimePresence("eod", playing, true);
|
||||
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||
setPlaying(phase === "playing");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||
const hideSidebarForLandscapeGame = () => {
|
||||
if (!playing || !landscapeQuery.matches) return;
|
||||
if (isMobile) setOpenMobile(false);
|
||||
else setOpen(false);
|
||||
};
|
||||
|
||||
hideSidebarForLandscapeGame();
|
||||
landscapeQuery.addEventListener("change", hideSidebarForLandscapeGame);
|
||||
return () => landscapeQuery.removeEventListener("change", hideSidebarForLandscapeGame);
|
||||
}, [isMobile, playing, setOpen, setOpenMobile]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<MinesweeperBoard
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -20,7 +20,6 @@ import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
|||
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
||||
|
||||
type Phase = "ready" | "playing" | "over";
|
||||
type GameMode = EodDifficulty | "custom";
|
||||
|
|
@ -39,6 +38,7 @@ const CUSTOM_MIN_SIZE = 9;
|
|||
const CUSTOM_MAX_SIZE = 500;
|
||||
const CUSTOM_MAX_SAFE_MINES_BUFFER = 9;
|
||||
const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900;
|
||||
const MOBILE_COLUMN_COUNT = 10;
|
||||
|
||||
type Cell = {
|
||||
readonly mine: boolean;
|
||||
|
|
@ -93,6 +93,19 @@ const NUMBER_CLASSES: Readonly<Record<number, string>> = {
|
|||
8: "text-gray-400",
|
||||
};
|
||||
|
||||
function getMobileConfig(config: GameConfig, landscape: boolean): GameConfig {
|
||||
if (landscape) return config;
|
||||
|
||||
const rows = Math.max(1, Math.ceil((config.rows * config.cols) / MOBILE_COLUMN_COUNT));
|
||||
const cells = rows * MOBILE_COLUMN_COUNT;
|
||||
return {
|
||||
...config,
|
||||
rows,
|
||||
cols: MOBILE_COLUMN_COUNT,
|
||||
mines: Math.min(config.mines, cells - CUSTOM_MAX_SAFE_MINES_BUFFER),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyBoard(size: number): Board {
|
||||
return Array.from({ length: size }, () => ({
|
||||
mine: false,
|
||||
|
|
@ -202,12 +215,17 @@ export function MinesweeperBoard({
|
|||
const [stats, setStats] = useState<EodStats>(serverStats);
|
||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
const [isNarrow, setIsNarrow] = useState(false);
|
||||
const generatedRef = useRef(false);
|
||||
const previousViewportRef = useRef<string | null>(null);
|
||||
const submittingRef = useRef(false);
|
||||
const statsRef = useRef<EodStats>(serverStats);
|
||||
const longPressRef = useRef(false);
|
||||
const longPressTimerRef = useRef<number | null>(null);
|
||||
const config: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
||||
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
||||
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
|
||||
const customUnlocked =
|
||||
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
|
||||
const isLargeCustomGame =
|
||||
|
|
@ -226,6 +244,27 @@ export function MinesweeperBoard({
|
|||
onPhaseChange("ready");
|
||||
}, [onPhaseChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const mobileQuery = window.matchMedia("(max-width: 639px)");
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||
const narrowQuery = window.matchMedia("(max-width: 1023px)");
|
||||
const updateViewport = () => {
|
||||
setIsMobile(mobileQuery.matches);
|
||||
setIsLandscape(landscapeQuery.matches);
|
||||
setIsNarrow(narrowQuery.matches);
|
||||
};
|
||||
|
||||
updateViewport();
|
||||
mobileQuery.addEventListener("change", updateViewport);
|
||||
landscapeQuery.addEventListener("change", updateViewport);
|
||||
narrowQuery.addEventListener("change", updateViewport);
|
||||
return () => {
|
||||
mobileQuery.removeEventListener("change", updateViewport);
|
||||
landscapeQuery.removeEventListener("change", updateViewport);
|
||||
narrowQuery.removeEventListener("change", updateViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "playing") return;
|
||||
const timer = window.setInterval(() => {
|
||||
|
|
@ -244,7 +283,11 @@ export function MinesweeperBoard({
|
|||
(nextDifficulty: GameMode = difficulty) => {
|
||||
if (submittingRef.current) return;
|
||||
if (expiredRef.current) return;
|
||||
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
||||
const selectedConfig =
|
||||
nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
||||
const nextConfig = isMobile
|
||||
? getMobileConfig(selectedConfig, isLandscape)
|
||||
: selectedConfig;
|
||||
setDifficulty(nextDifficulty);
|
||||
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
||||
setBoard(emptyBoard(nextConfig.rows * nextConfig.cols));
|
||||
|
|
@ -253,9 +296,20 @@ export function MinesweeperBoard({
|
|||
setLastRun(null);
|
||||
setPhaseSafe("ready");
|
||||
},
|
||||
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
|
||||
[customConfig, difficulty, expiredRef, isLandscape, isMobile, onDifficultyChange, setPhaseSafe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const viewportKey = `${isMobile}-${isLandscape}`;
|
||||
if (previousViewportRef.current === null) {
|
||||
previousViewportRef.current = viewportKey;
|
||||
return;
|
||||
}
|
||||
if (previousViewportRef.current === viewportKey) return;
|
||||
previousViewportRef.current = viewportKey;
|
||||
reset();
|
||||
}, [isLandscape, isMobile, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
|
|
@ -443,15 +497,23 @@ export function MinesweeperBoard({
|
|||
const renderGrid = (modal = false) => (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-auto rounded-lg border border-border bg-background",
|
||||
"min-w-0 max-w-full overflow-auto rounded-lg border border-border bg-background",
|
||||
modal
|
||||
? "min-h-full min-w-full items-start justify-start p-2"
|
||||
: "flex items-center justify-center p-2",
|
||||
)}
|
||||
style={{ containerType: "inline-size" }}
|
||||
>
|
||||
<div
|
||||
className="grid min-w-max gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
|
||||
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
|
||||
style={
|
||||
{
|
||||
"--board-cols": config.cols,
|
||||
"--cell-size": isNarrow
|
||||
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
|
||||
: "1.75rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
role="grid"
|
||||
aria-label={`${config.label} minefield`}
|
||||
>
|
||||
|
|
@ -469,7 +531,7 @@ export function MinesweeperBoard({
|
|||
aria-colindex={col + 1}
|
||||
disabled={phase === "over" || expired || cell.revealed}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
cell.revealed &&
|
||||
!cell.mine &&
|
||||
(highContrast
|
||||
|
|
@ -772,10 +834,20 @@ export function MinesweeperBoard({
|
|||
</div>
|
||||
|
||||
{!isLargeCustomGame && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border bg-background p-2 items-center justify-center flex">
|
||||
<div
|
||||
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
|
||||
style={{ containerType: "inline-size" }}
|
||||
>
|
||||
<div
|
||||
className="grid min-w-max gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
|
||||
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
|
||||
style={
|
||||
{
|
||||
"--board-cols": config.cols,
|
||||
"--cell-size": isNarrow
|
||||
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
|
||||
: "1.75rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
role="grid"
|
||||
aria-label={`${config.label} minefield`}
|
||||
>
|
||||
|
|
@ -793,7 +865,7 @@ export function MinesweeperBoard({
|
|||
aria-colindex={col + 1}
|
||||
disabled={phase === "over" || expired || cell.revealed}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
cell.revealed &&
|
||||
!cell.mine &&
|
||||
(highContrast
|
||||
|
|
|
|||
134
src/components/frontend/locker/LockerCharacterPanel.tsx
Normal file
134
src/components/frontend/locker/LockerCharacterPanel.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Loadout } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
|
||||
import {
|
||||
getLoadoutSlot,
|
||||
getWardrobeSlot,
|
||||
type LoadoutSlots,
|
||||
type LockerGridItem,
|
||||
} from "@/lib/locker";
|
||||
import { Wardrobe } from "./Wardrobe";
|
||||
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LockerCharacterPanelProps {
|
||||
loadouts: Loadout[];
|
||||
items: LockerGridItem[];
|
||||
}
|
||||
|
||||
export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelProps) {
|
||||
const router = useRouter();
|
||||
const equippedLoadout = loadouts.find((loadout) => loadout.equipped) ?? null;
|
||||
const [pickerSlot, setPickerSlot] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const slots = (equippedLoadout?.slots ?? {}) as LoadoutSlots;
|
||||
const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null;
|
||||
const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null;
|
||||
|
||||
async function changeLoadout(id: string) {
|
||||
setBusy(true);
|
||||
const result = await equipLoadout(Number(id));
|
||||
setBusy(false);
|
||||
if (result.success) router.refresh();
|
||||
}
|
||||
|
||||
async function pickSlot(assetId: number | null) {
|
||||
if (!equippedLoadout || !pickerSlot) return { success: false, error: "No loadout equipped." };
|
||||
setBusy(true);
|
||||
const result = await setLoadoutSlot(equippedLoadout.id, pickerSlot, assetId);
|
||||
setBusy(false);
|
||||
if (result.success) {
|
||||
setPickerSlot(null);
|
||||
router.refresh();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col border border-border/70 bg-card/35"
|
||||
aria-label="Character equipment"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-3 py-2">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-primary/80">
|
||||
CHARACTER
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">EQUIPMENT STATUS</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
equippedLoadout ? "bg-emerald-400" : "bg-muted-foreground/40",
|
||||
)}
|
||||
aria-label={equippedLoadout ? "Loadout equipped" : "No loadout equipped"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border/50 p-2">
|
||||
<Select
|
||||
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
||||
onValueChange={changeLoadout}
|
||||
disabled={busy || loadouts.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide">
|
||||
<SelectValue placeholder="SELECT LOADOUT" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadouts.map((loadout) => (
|
||||
<SelectItem key={loadout.id} value={String(loadout.id)}>
|
||||
{loadout.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<Wardrobe
|
||||
loadout={equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)}
|
||||
items={items}
|
||||
onSlotClick={setPickerSlot}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2 font-mono text-[10px] uppercase tracking-wide text-muted-foreground/60">
|
||||
<span>Click a slot to equip</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 rounded-sm px-2 text-[10px]"
|
||||
onClick={() => setPickerSlot("primary")}
|
||||
disabled={!equippedLoadout || busy}
|
||||
>
|
||||
EDIT
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pickerLayout && pickerConfig && (
|
||||
<WardrobeSlotPicker
|
||||
open={!!pickerSlot}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPickerSlot(null);
|
||||
}}
|
||||
slot={pickerConfig}
|
||||
items={items}
|
||||
currentValue={slots[pickerSlot as keyof LoadoutSlots] ?? null}
|
||||
busy={busy}
|
||||
onPick={pickSlot}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,7 +36,6 @@ import type { Asset } from "@/payload-types";
|
|||
import {
|
||||
ATTACHMENT_CATEGORIES,
|
||||
findLockerMergeTarget,
|
||||
getAssetTypeColor,
|
||||
getLockerGridOccupancy,
|
||||
getLockerItemFootprint,
|
||||
isAttachmentCompatible,
|
||||
|
|
@ -118,7 +117,6 @@ export function LockerItemFace({
|
|||
equipment?: LockerItemEquipment;
|
||||
}) {
|
||||
const fp = getLockerItemFootprint(asset, rotated ?? false);
|
||||
const bgColor = getAssetTypeColor(asset.assetType);
|
||||
const thumb = assetThumbnailUrl(asset);
|
||||
const displayName = equipment?.customName?.trim() || asset.name;
|
||||
const hasCustomName = !!equipment?.customName?.trim();
|
||||
|
|
@ -131,48 +129,54 @@ export function LockerItemFace({
|
|||
width: fp.width * cellSize - 2,
|
||||
height: fp.height * cellSize - 2,
|
||||
}}
|
||||
className="relative rounded-md overflow-hidden flex flex-col items-center justify-center gap-0.5 select-none"
|
||||
className="relative flex select-none flex-col items-center justify-center overflow-hidden rounded-sm bg-card/60"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
|
||||
{/* Subtle selection glow */}
|
||||
{(hasSkin || attachmentCount > 0 || hasCustomName) && (
|
||||
<div className="absolute top-0.5 right-0.5 flex flex-col items-end gap-0.5 z-10">
|
||||
<div className="absolute inset-0 bg-primary/5" />
|
||||
)}
|
||||
{(hasSkin || attachmentCount > 0 || hasCustomName) && (
|
||||
<div className="absolute right-0.5 top-0.5 z-10 flex flex-col items-end gap-0.5">
|
||||
{hasCustomName && (
|
||||
<span className="rounded-sm bg-sky-400 p-0.5 text-white shadow" title="Custom name">
|
||||
<span
|
||||
className="rounded-sm border border-primary/30 bg-primary/15 p-0.5 text-primary"
|
||||
title="Custom name"
|
||||
>
|
||||
<Tag className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{hasSkin && (
|
||||
<span className="rounded-sm bg-amber-400 p-0.5 text-white shadow" title="Skin applied">
|
||||
<span
|
||||
className="rounded-sm border border-amber-400/30 bg-amber-400/15 p-0.5 text-amber-200"
|
||||
title="Skin applied"
|
||||
>
|
||||
<Palette className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{attachmentCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-sm bg-primary px-0.5 py-0.5 text-white shadow"
|
||||
className="flex items-center gap-0.5 rounded-sm border border-primary/30 bg-primary/15 px-0.5 py-0.5 text-primary"
|
||||
title={`${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`}
|
||||
>
|
||||
<Crosshair className="size-2.5" />
|
||||
<span className="text-[8px] font-bold leading-none">{attachmentCount}</span>
|
||||
<span className="font-mono text-[10px] font-semibold leading-none tabular-nums">
|
||||
{attachmentCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center gap-1 p-0.5">
|
||||
{thumb ? (
|
||||
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" />
|
||||
<img src={thumb} alt={displayName} className="size-5.5 object-contain opacity-85" />
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">
|
||||
{getAssetIcon(asset.assetType, "size-5")}
|
||||
</span>
|
||||
<span className="text-primary/60">{getAssetIcon(asset.assetType, "size-4")}</span>
|
||||
)}
|
||||
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
|
||||
<span className="line-clamp-1 px-1 text-center text-[10px] font-medium leading-tight text-foreground/80">
|
||||
{displayName}
|
||||
</span>
|
||||
{quantity > 1 && (
|
||||
<span
|
||||
className="text-[9px] font-bold leading-none px-1 py-0.5 rounded-sm"
|
||||
style={{ backgroundColor: bgColor, color: "white" }}
|
||||
>
|
||||
<span className="rounded-sm bg-primary/15 px-0.5 py-0.5 font-mono text-[10px] font-semibold leading-none tabular-nums text-primary">
|
||||
{quantity.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -196,6 +200,7 @@ function DraggableLockerItem({
|
|||
}) {
|
||||
const didDrag = useRef(false);
|
||||
const fp = getLockerItemFootprint(item.asset, item.rotated);
|
||||
const displayName = item.equipment?.customName?.trim() || item.asset.name;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `grid-item-${item.id}`,
|
||||
|
|
@ -217,6 +222,15 @@ function DraggableLockerItem({
|
|||
[item, onSelect],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
},
|
||||
[item, onSelect],
|
||||
);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: item.gridX * cellSize,
|
||||
|
|
@ -234,15 +248,17 @@ function DraggableLockerItem({
|
|||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group rounded-md border cursor-grab active:cursor-grabbing select-none overflow-hidden",
|
||||
"transition-shadow duration-150",
|
||||
selected && "ring-2 ring-primary",
|
||||
!selected && "hover:ring-1 hover:ring-primary/50",
|
||||
highlight && "locker-search-highlight",
|
||||
"group cursor-grab select-none overflow-hidden rounded-sm border border-border/60 bg-card/80 active:cursor-grabbing",
|
||||
"transition-all duration-200 hover:border-border/90 hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
|
||||
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
!selected && "hover:ring-1 hover:ring-primary/40",
|
||||
highlight && "locker-search-highlight motion-reduce:animate-none",
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`${displayName}, quantity ${item.quantity.toLocaleString()}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<LockerItemFace
|
||||
|
|
@ -535,11 +551,18 @@ export function LockerGrid({
|
|||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Tactical grid frame */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="inventory-frame relative min-w-0 max-w-full overflow-auto overscroll-contain rounded border border-border bg-card/80 shadow-sm"
|
||||
role="region"
|
||||
aria-label="Locker inventory grid"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="relative grid"
|
||||
className="relative grid bg-background/10"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridWidth}, ${cellSize}px)`,
|
||||
gridTemplateRows: `repeat(${gridHeight}, ${cellSize}px)`,
|
||||
|
|
@ -578,25 +601,49 @@ export function LockerGrid({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{occupancy.used} / {occupancy.total} cells used
|
||||
</span>
|
||||
<span>
|
||||
{gridWidth} x {gridHeight}
|
||||
{/* Tactical metadata strip */}
|
||||
<div className="metadata-strip flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-2 py-2 text-[10px] text-muted-foreground/70">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
<span className="font-mono tabular-nums text-foreground/80">
|
||||
USAGE
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/40">:</span>
|
||||
<span className="font-mono tabular-nums text-foreground/80">
|
||||
{occupancy.used} / {occupancy.total}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-mono tabular-nums text-foreground/80">
|
||||
GRID
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground/40">:</span>
|
||||
<span className="font-mono tabular-nums text-foreground/80">
|
||||
{gridWidth}×{gridHeight}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline">
|
||||
SELECT to inspect · DRAG to move · R to rotate
|
||||
</span>
|
||||
</div>
|
||||
{dragError && <p className="text-xs text-red-400">{dragError}</p>}
|
||||
{dragError && (
|
||||
<p role="alert" className="text-[10px] text-destructive">
|
||||
{dragError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tactical drag overlay */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeItem && (
|
||||
<div className="rounded-md border border-primary/50 shadow-xl ring-2 ring-primary/50">
|
||||
<div className="rounded-sm border border-primary/50 bg-card shadow-lg ring-2 ring-primary/40">
|
||||
<LockerItemFace
|
||||
asset={activeItem.asset}
|
||||
quantity={activeItem.quantity}
|
||||
rotated={pendingRotated}
|
||||
cellSize={cellSize}
|
||||
equipment={activeItem.equipment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
lockerIsRotatable,
|
||||
lockerIsStackable,
|
||||
} from "@/lib/locker";
|
||||
import { getAssetIcon } from "./LockerGrid";
|
||||
|
||||
interface LockerGridToolbarProps {
|
||||
gridWidth: number;
|
||||
|
|
@ -60,72 +61,144 @@ export function LockerGridToolbar({
|
|||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||
<ShuffleIcon className="size-3.5 mr-1" />
|
||||
Auto-Arrange
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground/60">
|
||||
TOOLS
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground/80">ARRANGE // INSPECT</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||
<ShuffleIcon data-icon="inline-start" />
|
||||
AUTO
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wide">VIEW</span>
|
||||
<div className="flex items-center gap-1" role="group" aria-label="Inventory view">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "outline"}
|
||||
size="sm"
|
||||
size="icon"
|
||||
aria-label="Grid view"
|
||||
aria-pressed={viewMode === "grid"}
|
||||
title="Grid view"
|
||||
onClick={() => onViewModeChange("grid")}
|
||||
>
|
||||
<LayoutGridIcon className="size-3.5" />
|
||||
<LayoutGridIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "outline"}
|
||||
size="sm"
|
||||
size="icon"
|
||||
aria-label="List view"
|
||||
aria-pressed={viewMode === "list"}
|
||||
title="List view"
|
||||
onClick={() => onViewModeChange("list")}
|
||||
>
|
||||
<ListIcon className="size-3.5" />
|
||||
<ListIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedItem && asset && (
|
||||
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{displayName}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
|
||||
{hasCustomName && asset && displayName !== asset.name && ` · as "${asset.name}"`}
|
||||
{fp && ` · ${fp.width}x${fp.height}`}
|
||||
{selectedItem.rotated && " · rotated"}
|
||||
<div className="flex min-w-0 flex-col gap-2 border border-border/30 bg-card/40 p-2.5 text-[10px]">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center border border-border/30 bg-muted/40 text-primary/90">
|
||||
{getAssetIcon(asset.assetType)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground/90">{displayName}</p>
|
||||
<p className="truncate text-muted-foreground/60">
|
||||
{getAssetTypeLabel(asset.assetType)} · {selectedItem.quantity.toLocaleString()}×
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-2.5 gap-y-1.5 border-t border-border/50 pt-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground/70">FOOTPRINT</dt>
|
||||
<dd className="font-mono tabular-nums text-foreground/90">
|
||||
{fp ? `${fp.width}×${fp.height}` : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground/70">STATE</dt>
|
||||
<dd className="text-foreground/90">{selectedItem.rotated ? "ROTATED" : "READY"}</dd>
|
||||
</div>
|
||||
{hasCustomName && displayName !== asset.name && (
|
||||
<div className="col-span-2 min-w-0">
|
||||
<dt className="text-muted-foreground/70">CATALOG</dt>
|
||||
<dd className="truncate text-foreground/90">{asset.name}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{asset?.assetType === "weapon" && (
|
||||
<Button variant="outline" size="sm" onClick={onCustomize}>
|
||||
<SlidersHorizontalIcon className="size-3.5" />
|
||||
Customize
|
||||
<SlidersHorizontalIcon data-icon="inline-start" />
|
||||
CUSTOM
|
||||
</Button>
|
||||
)}
|
||||
{lockerIsRotatable(asset) && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||
<RotateCwIcon className="size-3.5" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Rotate selected item"
|
||||
title="Rotate selected item"
|
||||
onClick={() => onRotate(selectedItem.id)}
|
||||
>
|
||||
<RotateCwIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{lockerIsStackable(asset) && selectedItem.quantity > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
disabled={!canSplit}
|
||||
aria-label="Split selected stack"
|
||||
title="Split selected stack"
|
||||
onClick={() => onSplit(selectedItem.id, Math.floor(selectedItem.quantity / 2))}
|
||||
>
|
||||
<ScissorsIcon className="size-3.5" />
|
||||
<ScissorsIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Remove selected item"
|
||||
title="Remove selected item"
|
||||
onClick={() => onRemove(selectedItem.id)}
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedItem && (
|
||||
<div className="border border-dashed border-border/30 bg-muted/20 p-2.5">
|
||||
<p className="text-[10px] font-medium text-muted-foreground/60 uppercase">NO SELECTION</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground/50">
|
||||
Select a stored item to inspect its footprint and actions.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-2 py-2 text-[10px] text-muted-foreground/60">
|
||||
<span className="hidden sm:block">
|
||||
<span className="font-mono tabular-nums text-foreground/80">R</span>
|
||||
<span className="mx-1 text-muted-foreground/40">to rotate</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-mono tabular-nums text-foreground/80">USAGE</span>
|
||||
<span className="mx-1 text-muted-foreground/40">:</span>
|
||||
<span className="font-mono tabular-nums text-foreground/80">
|
||||
{occupancy.used} / {occupancy.total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
} from "@/app/(frontend)/locker/actions";
|
||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||
import { LayoutGridIcon } from "lucide-react";
|
||||
import { LockerCharacterPanel } from "./LockerCharacterPanel";
|
||||
|
||||
interface LockerViewProps {
|
||||
locker: LockerStorage;
|
||||
|
|
@ -47,7 +48,7 @@ export function LockerView({
|
|||
userName,
|
||||
}: LockerViewProps) {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState("wardrobe");
|
||||
const [tab, setTab] = useState("inventory");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [customizeId, setCustomizeId] = useState<string | null>(null);
|
||||
|
|
@ -195,17 +196,26 @@ export function LockerView({
|
|||
);
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="wardrobe">Wardrobe</TabsTrigger>
|
||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
className="flex min-w-0 w-full max-w-none flex-col gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<TabsList variant="line" aria-label="Locker sections" className="w-full sm:w-auto">
|
||||
<TabsTrigger value="wardrobe">WARDROBE</TabsTrigger>
|
||||
<TabsTrigger value="inventory">INVENTORY</TabsTrigger>
|
||||
<TabsTrigger value="loadouts">LOADOUTS</TabsTrigger>
|
||||
</TabsList>
|
||||
<LockerSearch items={items} loadouts={loadouts} onSelect={handleSearchSelect} />
|
||||
<div className="flex shrink-0 items-center gap-3 self-start sm:self-auto">
|
||||
<span className="hidden font-mono text-[10px] tabular-nums text-muted-foreground/60 lg:inline">
|
||||
{items.length.toLocaleString()} ITEMS · {gridWidth}×{gridHeight} GRID
|
||||
</span>
|
||||
<LockerSearch items={items} loadouts={loadouts} onSelect={handleSearchSelect} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="wardrobe">
|
||||
<TabsContent value="wardrobe" className="mt-0">
|
||||
<WardrobePanel
|
||||
userName={userName}
|
||||
loadouts={loadouts}
|
||||
|
|
@ -214,88 +224,119 @@ export function LockerView({
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="inventory" className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<LockerGridToolbar
|
||||
gridWidth={gridWidth}
|
||||
gridHeight={gridHeight}
|
||||
items={items}
|
||||
selectedItem={selectedItem}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
onAutoArrange={handleAutoArrange}
|
||||
onRotate={handleRotateItem}
|
||||
onRemove={handleRemoveItem}
|
||||
onSplit={handleSplitStack}
|
||||
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
|
||||
/>
|
||||
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
|
||||
<TabsContent value="inventory" className="mt-0 flex min-w-0 w-full flex-col gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-2 border-y border-border/50 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs font-medium uppercase tracking-[0.18em] text-foreground/90">
|
||||
STASH // PERSONAL STORAGE
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
DRAG TO REPOSITION · SELECT TO INSPECT · R TO ROTATE
|
||||
</p>
|
||||
</div>
|
||||
{isManager && (
|
||||
<div className="shrink-0">
|
||||
<AddLockerItemDialog assets={assetsCatalog} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<ItemTitle className="text-muted-foreground">Empty Locker</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isManager
|
||||
? "Your locker is empty. Add items to get started."
|
||||
: "Your locker is empty. Reach out to logistics to have equipment issued."}
|
||||
</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : viewMode === "grid" ? (
|
||||
<LockerGrid
|
||||
gridWidth={gridWidth}
|
||||
gridHeight={gridHeight}
|
||||
items={items}
|
||||
onMoveItem={handleMoveItem}
|
||||
onMergeStacks={handleMergeStacks}
|
||||
onRotateItem={handleRotateItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
|
||||
selectedItem={selectedItem}
|
||||
highlightId={highlightId}
|
||||
onAttachItem={handleAttachItem}
|
||||
/>
|
||||
) : (
|
||||
<Item variant="outline">
|
||||
<ItemContent>
|
||||
<div className="flex flex-col">
|
||||
{items.map((entry, index) => (
|
||||
<div key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 py-2.5 w-full text-left"
|
||||
onClick={() => setSelectedId(entry.id)}
|
||||
>
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">
|
||||
{entry.equipment?.customName?.trim() || entry.asset.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getAssetTypeLabel(entry.asset.assetType)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{entry.quantity.toLocaleString()}×
|
||||
</span>
|
||||
</button>
|
||||
{index < items.length - 1 && <div className="border-t border-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
<div className="grid min-w-0 items-start gap-4 xl:grid-cols-[minmax(24rem,30rem)_minmax(0,1fr)]">
|
||||
<LockerCharacterPanel loadouts={loadouts} items={items} />
|
||||
|
||||
<EventLedger
|
||||
targetCollection="locker-storages"
|
||||
targetId={locker.id}
|
||||
refreshKey={eventLogKey}
|
||||
/>
|
||||
<div className="grid min-w-0 items-start gap-3 2xl:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="min-w-0">
|
||||
{items.length === 0 ? (
|
||||
<Item variant="outline" className="rounded-sm border border-border/30 bg-card/40">
|
||||
<ItemHeader>
|
||||
<ItemTitle className="text-muted-foreground/70">LOCKER EMPTY</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground/60">
|
||||
{isManager
|
||||
? "Your locker is empty. Add items to get started."
|
||||
: "Your locker is empty. Reach out to logistics to have equipment issued."}
|
||||
</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : viewMode === "grid" ? (
|
||||
<LockerGrid
|
||||
gridWidth={gridWidth}
|
||||
gridHeight={gridHeight}
|
||||
items={items}
|
||||
onMoveItem={handleMoveItem}
|
||||
onMergeStacks={handleMergeStacks}
|
||||
onRotateItem={handleRotateItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
|
||||
selectedItem={selectedItem}
|
||||
highlightId={highlightId}
|
||||
onAttachItem={handleAttachItem}
|
||||
/>
|
||||
) : (
|
||||
<Item
|
||||
variant="outline"
|
||||
className="rounded-sm border border-border/30 bg-card/40 p-0"
|
||||
>
|
||||
<ItemContent className="gap-0 p-1.5">
|
||||
<div className="flex flex-col">
|
||||
{items.map((entry, index) => (
|
||||
<div key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left text-sm transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset motion-reduce:transition-none"
|
||||
onClick={() => setSelectedId(entry.id)}
|
||||
aria-label={`Select ${entry.equipment?.customName?.trim() || entry.asset.name}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground/90">
|
||||
{entry.equipment?.customName?.trim() || entry.asset.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/50 uppercase">
|
||||
{getAssetTypeLabel(entry.asset.assetType)}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
|
||||
{entry.quantity.toLocaleString()}×
|
||||
</span>
|
||||
</button>
|
||||
{index < items.length - 1 && (
|
||||
<div className="border-t border-border/30" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className="selection-rail flex min-w-0 flex-col gap-3 2xl:sticky 2xl:top-3"
|
||||
aria-label="Selected item and locker activity"
|
||||
>
|
||||
<LockerGridToolbar
|
||||
gridWidth={gridWidth}
|
||||
gridHeight={gridHeight}
|
||||
items={items}
|
||||
selectedItem={selectedItem}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
onAutoArrange={handleAutoArrange}
|
||||
onRotate={handleRotateItem}
|
||||
onRemove={handleRemoveItem}
|
||||
onSplit={handleSplitStack}
|
||||
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
|
||||
/>
|
||||
<EventLedger
|
||||
targetCollection="locker-storages"
|
||||
targetId={locker.id}
|
||||
refreshKey={eventLogKey}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="loadouts">
|
||||
<TabsContent value="loadouts" className="mt-0">
|
||||
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Backpack,
|
||||
Crosshair,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import type { Asset, Loadout } from "@/payload-types";
|
||||
import {
|
||||
getAssetTypeLabel,
|
||||
type LoadoutSlots,
|
||||
type LockerGridItem,
|
||||
WARDROBE_SLOT_LAYOUT,
|
||||
|
|
@ -23,8 +23,8 @@ import { cn } from "@/lib/utils";
|
|||
import { getAssetIcon } from "./LockerGrid";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
function slotIcon(icon: WardrobeSlotIcon, filled: boolean) {
|
||||
const cls = filled ? "size-3.5" : "size-3";
|
||||
function slotIcon(icon: WardrobeSlotIcon) {
|
||||
const cls = "size-3.5";
|
||||
switch (icon) {
|
||||
case "head":
|
||||
return <User className={cls} />;
|
||||
|
|
@ -57,74 +57,224 @@ function resolveSlotAsset(
|
|||
return items.find((i) => i.assetId === id)?.asset ?? null;
|
||||
}
|
||||
|
||||
function resolveSlotDisplayName(asset: Asset, items: LockerGridItem[]): string {
|
||||
const custom = items.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim();
|
||||
return custom || asset.name;
|
||||
}
|
||||
|
||||
const WARDROBE_REGIONS = [
|
||||
{ id: "head", label: "HEAD / FACE", description: "Protection and optics" },
|
||||
{ id: "torso", label: "TORSO", description: "Carrier and uniform" },
|
||||
{ id: "pack", label: "PACK", description: "Field storage" },
|
||||
{ id: "weapons", label: "WEAPONS / ATTACHMENTS", description: "Primary, heavy, and weapon fit" },
|
||||
{ id: "sidearm", label: "SIDEARM", description: "Secondary weapon" },
|
||||
] as const;
|
||||
|
||||
type WardrobeRegionId = (typeof WARDROBE_REGIONS)[number]["id"];
|
||||
|
||||
function getWardrobeRegion(zone: string): WardrobeRegionId {
|
||||
switch (zone) {
|
||||
case "helmet":
|
||||
case "nvg":
|
||||
case "facewear":
|
||||
return "head";
|
||||
case "radio":
|
||||
case "vest":
|
||||
case "uniform":
|
||||
return "torso";
|
||||
case "backpack":
|
||||
return "pack";
|
||||
case "secondary":
|
||||
return "sidearm";
|
||||
default:
|
||||
return "weapons";
|
||||
}
|
||||
}
|
||||
|
||||
const WARDROBE_REGION_SLOTS = WARDROBE_REGIONS.map((region) => ({
|
||||
...region,
|
||||
slots: WARDROBE_SLOT_LAYOUT.filter((layout) => getWardrobeRegion(layout.zone) === region.id),
|
||||
}));
|
||||
|
||||
function WardrobeSlotButton({
|
||||
layout,
|
||||
asset,
|
||||
displayName,
|
||||
onSlotClick,
|
||||
}: {
|
||||
readonly layout: (typeof WARDROBE_SLOT_LAYOUT)[number];
|
||||
readonly asset: Asset | null;
|
||||
readonly displayName: string | null;
|
||||
readonly onSlotClick: (slotName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${layout.label} slot: ${displayName ?? "empty"}`}
|
||||
data-slot-state={asset ? "filled" : "empty"}
|
||||
onClick={() => onSlotClick(layout.name)}
|
||||
className={cn(
|
||||
"group flex min-h-20 min-w-0 cursor-pointer flex-col gap-3 rounded-sm border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
|
||||
asset
|
||||
? "border-primary/40 bg-primary/10 hover:border-primary/70 hover:bg-primary/15"
|
||||
: "border-border/70 bg-background/30 text-muted-foreground hover:border-primary/50 hover:bg-muted/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-sm border",
|
||||
asset
|
||||
? "border-primary/30 bg-primary/15 text-primary"
|
||||
: "border-border/60 bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{asset ? getAssetIcon(asset.assetType) : slotIcon(layout.icon)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block whitespace-normal text-xs font-semibold leading-tight text-foreground/90">
|
||||
{layout.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"block whitespace-normal text-xs font-mono uppercase leading-tight tracking-wide",
|
||||
asset ? "text-primary/80" : "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
{displayName ?? "EMPTY"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-right text-[10px] font-mono uppercase leading-tight tracking-wide whitespace-nowrap",
|
||||
asset ? "text-primary" : "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{asset ? "EQUIPPED" : "OPEN"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 border-t border-border/40 pt-2 text-xs text-muted-foreground/70">
|
||||
<span className="whitespace-normal leading-tight">
|
||||
{asset ? getAssetTypeLabel(asset.assetType) : layout.label}
|
||||
</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="whitespace-normal leading-tight">
|
||||
{asset ? "READY" : "SELECT TO EQUIP"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8} className="text-sm">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{layout.label}</span>
|
||||
<span className="text-muted-foreground">{displayName ?? "Empty"}</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function Wardrobe({
|
||||
loadout,
|
||||
items,
|
||||
onSlotClick,
|
||||
}: {
|
||||
loadout: Loadout;
|
||||
items: LockerGridItem[];
|
||||
onSlotClick: (slotName: string) => void;
|
||||
readonly loadout: Loadout;
|
||||
readonly items: LockerGridItem[];
|
||||
readonly onSlotClick: (slotName: string) => void;
|
||||
}) {
|
||||
const slots = (loadout.slots ?? {}) as LoadoutSlots;
|
||||
const filledCount = WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null).length;
|
||||
|
||||
const filled = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const layout of WARDROBE_SLOT_LAYOUT) {
|
||||
const value = slots[layout.name];
|
||||
if (value != null) set.add(layout.zone);
|
||||
}
|
||||
return set;
|
||||
}, [slots]);
|
||||
const renderSlot = (layout: (typeof WARDROBE_SLOT_LAYOUT)[number]) => {
|
||||
const asset = resolveSlotAsset(slots[layout.name], items);
|
||||
return (
|
||||
<WardrobeSlotButton
|
||||
key={layout.name}
|
||||
layout={layout}
|
||||
asset={asset}
|
||||
displayName={asset ? resolveSlotDisplayName(asset, items) : null}
|
||||
onSlotClick={onSlotClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-100">
|
||||
<div className="relative" style={{ aspectRatio: "260 / 380" }}>
|
||||
<img
|
||||
src="/images/soldier-pose-alpha.png"
|
||||
alt="Character wardrobe reflecting the equipped loadout"
|
||||
className="h-full w-full object-contain pointer-events-none select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="@container w-full min-w-0">
|
||||
<div className="flex flex-col gap-3 border border-border/60 bg-card/40 p-3">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/50 pb-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs uppercase tracking-widest text-muted-foreground/70">
|
||||
EQUIPMENT MATRIX
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground/90">
|
||||
SELECT A SLOT TO CONFIGURE YOUR LOADOUT
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="font-mono text-xs tabular-nums text-primary">
|
||||
{filledCount}/{WARDROBE_SLOT_LAYOUT.length}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground/60">READY</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{WARDROBE_SLOT_LAYOUT.map((layout) => {
|
||||
const value = slots[layout.name];
|
||||
const asset = resolveSlotAsset(value, items);
|
||||
return (
|
||||
<Tooltip key={layout.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${layout.label} slot: ${asset ? asset.name : "empty"}`}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSlotClick(layout.name);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center rounded-full border transition-colors",
|
||||
"-translate-x-1/2 -translate-y-1/2 cursor-pointer",
|
||||
asset
|
||||
? "size-7 border-amber-400/70 bg-amber-500/25 text-amber-200 hover:bg-amber-500/40"
|
||||
: "size-6 border-border bg-background/80 text-muted-foreground hover:border-primary hover:text-foreground",
|
||||
)}
|
||||
style={{ left: `${layout.x}%`, top: `${layout.y}%` }}
|
||||
>
|
||||
{asset ? getAssetIcon(asset.assetType, "size-3.5") : slotIcon(layout.icon, false)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="text-sm px-3.5 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{layout.label}</span>
|
||||
<span className="text-muted-foreground">{asset ? asset.name : "Empty"}</span>
|
||||
<div className="grid min-w-0 gap-3 @sm:grid-cols-2 @xl:grid-cols-3">
|
||||
{WARDROBE_REGION_SLOTS.map((region) => (
|
||||
<section
|
||||
key={region.id}
|
||||
aria-labelledby={`wardrobe-region-${region.id}`}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-2 border-2 border-border/80 bg-background/20 p-3",
|
||||
region.id === "head" && "@xl:col-span-3",
|
||||
region.id === "torso" && "@xl:col-span-2",
|
||||
region.id === "weapons" && "@xl:col-span-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 border-b border-border/70 pb-2">
|
||||
<div className="min-w-0">
|
||||
<h3
|
||||
id={`wardrobe-region-${region.id}`}
|
||||
className="whitespace-normal break-words text-xs font-semibold tracking-wide text-foreground/90"
|
||||
>
|
||||
{region.label}
|
||||
</h3>
|
||||
<p className="whitespace-normal break-words text-xs text-muted-foreground/60">
|
||||
{region.description}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/60">
|
||||
{region.slots.length} SLOT{region.slots.length === 1 ? "" : "S"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{region.id === "weapons" ? (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
{region.slots.filter((layout) => layout.name === "primary").map(renderSlot)}
|
||||
<div className="ml-3 flex min-w-0 flex-col gap-2 border-l-2 border-border/70 pl-3">
|
||||
<p className="font-mono text-xs uppercase tracking-wide text-muted-foreground/70">
|
||||
PRIMARY ATTACHMENTS
|
||||
</p>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2">
|
||||
{region.slots
|
||||
.filter((layout) => ["optic", "muzzle", "bipod"].includes(layout.name))
|
||||
.map(renderSlot)}
|
||||
</div>
|
||||
</div>
|
||||
{region.slots.filter((layout) => layout.name === "launcher").map(renderSlot)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2">
|
||||
{region.slots.map(renderSlot)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
: [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_1fr] gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,40rem)_minmax(0,1fr)]">
|
||||
<Item variant="outline" className="self-start">
|
||||
<ItemHeader>
|
||||
<ItemTitle>Character</ItemTitle>
|
||||
|
|
@ -93,9 +93,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
items={items}
|
||||
onSlotClick={(name) => setPickerSlot(name)}
|
||||
/>
|
||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||
<p className="mt-3 text-center text-sm text-muted-foreground">
|
||||
{equippedLoadout
|
||||
? "Click a highlighted point to equip gear from your locker."
|
||||
? "Select a slot block to equip gear from your locker."
|
||||
: "Equip a loadout to start dressing your character."}
|
||||
</p>
|
||||
</ItemContent>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "preferences_display_show_background_texture" boolean DEFAULT true;
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "users"
|
||||
DROP COLUMN "preferences_display_show_background_texture";
|
||||
`);
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import * as migration_20260820_004157 from './20260820_004157';
|
||||
import * as migration_20260825_220000 from './20260825_220000';
|
||||
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
|
||||
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
|
||||
import * as migration_20260827_005757 from './20260827_005757';
|
||||
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
|
||||
import * as migration_20260820_004157 from "./20260820_004157";
|
||||
import * as migration_20260825_220000 from "./20260825_220000";
|
||||
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
|
||||
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
|
||||
import * as migration_20260827_005757 from "./20260827_005757";
|
||||
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
|
||||
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
|
||||
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
|
||||
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
|
||||
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
|
||||
|
|
@ -13,32 +14,32 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260820_004157.up,
|
||||
down: migration_20260820_004157.down,
|
||||
name: '20260820_004157',
|
||||
name: "20260820_004157",
|
||||
},
|
||||
{
|
||||
up: migration_20260825_220000.up,
|
||||
down: migration_20260825_220000.down,
|
||||
name: '20260825_220000',
|
||||
name: "20260825_220000",
|
||||
},
|
||||
{
|
||||
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
|
||||
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
|
||||
name: '20260826_050000_repair_shims_auxiliary_tables',
|
||||
name: "20260826_050000_repair_shims_auxiliary_tables",
|
||||
},
|
||||
{
|
||||
up: migration_20260826_060000_add_game_servers_event_target.up,
|
||||
down: migration_20260826_060000_add_game_servers_event_target.down,
|
||||
name: '20260826_060000_add_game_servers_event_target',
|
||||
name: "20260826_060000_add_game_servers_event_target",
|
||||
},
|
||||
{
|
||||
up: migration_20260827_005757.up,
|
||||
down: migration_20260827_005757.down,
|
||||
name: '20260827_005757',
|
||||
name: "20260827_005757",
|
||||
},
|
||||
{
|
||||
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
|
||||
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
|
||||
name: '20260827_022334_add_eod_minesweeper_best_flags',
|
||||
name: "20260827_022334_add_eod_minesweeper_best_flags",
|
||||
},
|
||||
{
|
||||
up: migration_20260827_213857_add_evaluations_collection.up,
|
||||
|
|
@ -50,6 +51,11 @@ export const migrations = [
|
|||
down: migration_20260827_233224_add_mission_completed_at.down,
|
||||
name: '20260827_233224_add_mission_completed_at',
|
||||
},
|
||||
{
|
||||
up: migration_20260828_011500_add_background_texture_preference.up,
|
||||
down: migration_20260828_011500_add_background_texture_preference.down,
|
||||
name: "20260828_011500_add_background_texture_preference",
|
||||
},
|
||||
{
|
||||
up: migration_20260828_041615_add_campaign_mode.up,
|
||||
down: migration_20260828_041615_add_campaign_mode.down,
|
||||
|
|
|
|||
|
|
@ -1181,6 +1181,7 @@ export interface User {
|
|||
leadOrActual?: ('lead' | 'actual') | null;
|
||||
showVersionOverlay?: boolean | null;
|
||||
xpDisplayMini?: boolean | null;
|
||||
showBackgroundTexture?: boolean | null;
|
||||
};
|
||||
discord?: {
|
||||
enabled?: boolean | null;
|
||||
|
|
@ -3409,6 +3410,7 @@ export interface UsersSelect<T extends boolean = true> {
|
|||
leadOrActual?: T;
|
||||
showVersionOverlay?: T;
|
||||
xpDisplayMini?: T;
|
||||
showBackgroundTexture?: T;
|
||||
};
|
||||
discord?:
|
||||
| T
|
||||
|
|
|
|||
240
src/tools/import/extractArmaIcons.ts
Normal file
240
src/tools/import/extractArmaIcons.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Extract Arma inventory .paa icons referenced by an export catalog out of the
|
||||
* game's packed PBOs into a --paa-root tree shaped like the reference paths,
|
||||
* so importArmaLibrary.ts's resolvePaaPath() can find them for PAA->PNG.
|
||||
*
|
||||
* Resolution is ref-driven: the second path segment names the PBO (vanilla
|
||||
* refs look like "\a3\weapons_f\Rifles\MX\data\UI\gear_mx_cqc_M_CA.paa"), so we
|
||||
* locate that PBO by basename and inspect it on demand (cached), then map the
|
||||
* ref to a file inside it. A fabricated/unresolvable ref reports MISS rather
|
||||
* than failing the run.
|
||||
*
|
||||
* CLI:
|
||||
* --catalog <file> export catalog JSON (array of records with "picture")
|
||||
* --icons <file> icon_map JSON (map of classname -> .paa ref) [optional]
|
||||
* --game <dir> Arma 3 install root (contains <game>/Addons/*.pbo)
|
||||
* [--mod <dir>]... extra mod roots to search for .pbo files
|
||||
* --paa-root <dir> where extracted .paa files are written (mirrors refs)
|
||||
* [--out-dir <dir>] write a machine-readable report here (optional)
|
||||
* [--hemtt <path>] hemtt binary (default: resolve from PATH)
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
interface CliOpts {
|
||||
catalog?: string[];
|
||||
icons?: string[];
|
||||
game?: string[];
|
||||
mod?: string[];
|
||||
"paa-root"?: string[];
|
||||
"out-dir"?: string[];
|
||||
hemtt?: string[];
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith("--") && i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
||||
const key = a.slice(2) as keyof CliOpts;
|
||||
(opts[key] ??= []).push(argv[++i]);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function resolveHemtt(explicit?: string): string {
|
||||
if (explicit && existsSync(explicit)) return explicit;
|
||||
const probe = spawnSync("which", ["hemtt"], { encoding: "utf8" });
|
||||
if (probe.status === 0 && probe.stdout.trim()) return probe.stdout.trim();
|
||||
throw new Error("hemtt not found; pass --hemtt <path>");
|
||||
}
|
||||
|
||||
interface PboInfo {
|
||||
pboPath: string;
|
||||
prefix: string;
|
||||
/** lowercase file path -> exact original-case path as stored in the PBO */
|
||||
files: Map<string, string>;
|
||||
}
|
||||
|
||||
function discoverPbos(game: string, mods: string[]): string[] {
|
||||
const dirs = [join(game, "Addons")];
|
||||
for (const m of mods) {
|
||||
const cand = join(m, "addons");
|
||||
if (existsSync(cand)) dirs.push(cand);
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const d of dirs) {
|
||||
if (!existsSync(d)) continue;
|
||||
for (const f of readFileSync("/dev/null").length ? [] : []) void f;
|
||||
const entries = spawnSync("ls", [d], { encoding: "utf8" });
|
||||
for (const line of (entries.stdout ?? "").split("\n")) {
|
||||
const n = line.trim();
|
||||
if (n.toLowerCase().endsWith(".pbo")) out.push(join(d, n));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pbosByName(pbos: string[]): Map<string, string> {
|
||||
const byName = new Map<string, string>();
|
||||
for (const p of pbos) {
|
||||
const base = p.split(/[\\/]/).pop()!.replace(/\.pbo$/i, "").toLowerCase();
|
||||
if (!byName.has(base)) byName.set(base, p);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: string[] } | undefined {
|
||||
const r = spawnSync(
|
||||
hemtt,
|
||||
["utils", "pbo", "inspect", "--format", "json", pboPath],
|
||||
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (r.status !== 0 || !r.stdout) return undefined;
|
||||
let data: { files?: { name?: string }[]; header?: { properties?: Record<string, string> } };
|
||||
try {
|
||||
data = JSON.parse(r.stdout);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const files = (data.files ?? []).map((f) => f.name ?? "").filter(Boolean);
|
||||
const props = data.header?.properties ?? {};
|
||||
const prefix = (props.prefix ?? "").trim().toLowerCase().replace(/\\/g, "/");
|
||||
return { prefix, files };
|
||||
}
|
||||
|
||||
function ensurePbo(
|
||||
hemtt: string,
|
||||
pboPath: string,
|
||||
cache: Map<string, PboInfo>,
|
||||
): PboInfo | undefined {
|
||||
if (cache.has(pboPath)) return cache.get(pboPath);
|
||||
const info = inspectPbo(hemtt, pboPath);
|
||||
if (!info || !info.prefix) return undefined;
|
||||
const files = new Map<string, string>();
|
||||
for (const f of info.files) files.set(f.toLowerCase(), f);
|
||||
const pbo: PboInfo = { pboPath, prefix: info.prefix, files };
|
||||
cache.set(pboPath, pbo);
|
||||
return pbo;
|
||||
}
|
||||
|
||||
function normalizeRef(src: string): string {
|
||||
return src.replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function resolveRef(
|
||||
norm: string,
|
||||
pbos: string[],
|
||||
byName: Map<string, string>,
|
||||
inspected: Map<string, PboInfo>,
|
||||
hemtt: string,
|
||||
): { pbo: PboInfo; relFile: string } | undefined {
|
||||
const seg = norm.split("/").filter(Boolean);
|
||||
const pboName = seg[1]?.toLowerCase();
|
||||
if (pboName && byName.has(pboName)) {
|
||||
const pbo = ensurePbo(hemtt, byName.get(pboName)!, inspected);
|
||||
if (pbo) {
|
||||
const prefixSlash = pbo.prefix + "/";
|
||||
if (norm.startsWith(prefixSlash)) {
|
||||
const relLower = norm.slice(pbo.prefix.length + 1).replace(/\//g, "\\");
|
||||
const exact = pbo.files.get(relLower);
|
||||
if (exact) return { pbo, relFile: exact };
|
||||
}
|
||||
}
|
||||
}
|
||||
let best: { prefix: string; pbo: PboInfo } | undefined;
|
||||
for (const pbo of inspected.values()) {
|
||||
const prefixSlash = pbo.prefix + "/";
|
||||
if (norm.startsWith(prefixSlash) && (!best || pbo.prefix.length > best.prefix.length)) {
|
||||
best = { prefix: pbo.prefix, pbo };
|
||||
}
|
||||
}
|
||||
if (!best) return undefined;
|
||||
const relLower = norm.slice(best.prefix.length + 1).replace(/\//g, "\\");
|
||||
const exact = best.pbo.files.get(relLower);
|
||||
return exact ? { pbo: best.pbo, relFile: exact } : undefined;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
const hemtt = resolveHemtt(opts.hemtt?.[0]);
|
||||
const game = opts.game?.[0];
|
||||
if (!game || !existsSync(game)) throw new Error("--game <dir> is required and must exist");
|
||||
if (!opts["paa-root"]?.[0]) throw new Error("--paa-root <dir> is required");
|
||||
const paaRoot = opts["paa-root"][0];
|
||||
const catalogPath = opts.catalog?.[0];
|
||||
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required");
|
||||
|
||||
const pbos = discoverPbos(game, opts.mod ?? []);
|
||||
console.log(`Discovered ${pbos.length} PBOs across ${1 + (opts.mod?.length ?? 0)} roots.`);
|
||||
const byName = pbosByName(pbos);
|
||||
const inspected = new Map<string, PboInfo>();
|
||||
|
||||
interface Ref { src: string; origin: string; }
|
||||
const refs = new Map<string, Ref>();
|
||||
const push = (src: string, origin: string) => {
|
||||
const norm = normalizeRef(src);
|
||||
if (!norm || !norm.endsWith(".paa")) return;
|
||||
if (!refs.has(norm)) refs.set(norm, { src: src.replace(/\\/g, "/").replace(/^\/+/, ""), origin });
|
||||
};
|
||||
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||
for (const rec of Array.isArray(catalog) ? catalog : [catalog]) {
|
||||
if (typeof rec?.picture === "string") push(rec.picture, "catalog");
|
||||
}
|
||||
const iconsPath = opts.icons?.[0];
|
||||
if (iconsPath && existsSync(iconsPath)) {
|
||||
const icons = JSON.parse(readFileSync(iconsPath, "utf8"));
|
||||
for (const v of Object.values(icons)) push(String(v), "icon_map");
|
||||
}
|
||||
|
||||
console.log(`${refs.size} unique .paa refs to extract.\n`);
|
||||
const report: string[] = [];
|
||||
let ok = 0, missing = 0, extractErr = 0;
|
||||
for (const ref of refs.values()) {
|
||||
const norm = normalizeRef(ref.src);
|
||||
const dest = join(paaRoot, ...ref.src.split("/"));
|
||||
if (existsSync(dest)) {
|
||||
console.log(` CACHE ${ref.origin}\t${ref.src}`);
|
||||
ok++;
|
||||
continue;
|
||||
}
|
||||
const res = resolveRef(norm, pbos, byName, inspected, hemtt);
|
||||
if (!res) {
|
||||
console.log(` MISS ${ref.origin}\t${ref.src} (not in any indexed PBO)`);
|
||||
report.push(`MISS\t${ref.origin}\t${ref.src}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
const r = spawnSync(
|
||||
hemtt,
|
||||
["utils", "pbo", "extract", res.pbo.pboPath, res.relFile, dest],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (r.status === 0 && existsSync(dest)) {
|
||||
console.log(` EXTRACTED ${ref.origin}\t${ref.src}`);
|
||||
report.push(`EXTRACTED\t${ref.origin}\t${ref.src}`);
|
||||
ok++;
|
||||
} else {
|
||||
console.log(` ERR ${ref.origin}\t${ref.src} (extract failed)`);
|
||||
report.push(`ERR\t${ref.origin}\t${ref.src}`);
|
||||
extractErr++;
|
||||
}
|
||||
}
|
||||
console.log(`\nDone: ${ok} available, ${missing} missing, ${extractErr} extract errors.`);
|
||||
const outDir = opts["out-dir"]?.[0];
|
||||
if (outDir) {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const { writeFileSync } = await import("fs");
|
||||
writeFileSync(join(outDir, "paa_report.txt"), report.join("\n") + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
249
src/tools/import/importArmaLibrary.ts
Normal file
249
src/tools/import/importArmaLibrary.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Import an Arma inventory export into the Polaris Task Force Payload CMS.
|
||||
*
|
||||
* Consumes a catalog JSON produced by the in-game ptf_export addon (records
|
||||
* carrying classname, display name, type, description, picture) plus an
|
||||
* optional icon_map, and upserts rows into the `assets` / `vehicles`
|
||||
* collections. Thumbnails are materialized from .paa inventory icons found
|
||||
* under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via
|
||||
* `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied
|
||||
* via --screenshots (matched by classname).
|
||||
*
|
||||
* CLI:
|
||||
* --catalog <file> export catalog JSON (required)
|
||||
* [--icons <file>] icon_map JSON (classname -> .paa ref)
|
||||
* [--paa-root <dir>] root mirroring ref paths where .paa thumbnails live
|
||||
* [--screenshots <dir>] PNG/WebP per-classname 3D-rendered previews
|
||||
* [--out <dir>] write converted thumbnails + a report here
|
||||
* [--hemtt <path>] hemtt binary (default: resolve from PATH)
|
||||
* [--approve] apply to the DB (default: proposal only)
|
||||
* [--dry-run] plan + print, never touch the DB or filesystem
|
||||
* [--limit <n>] import at most n records
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
interface CliOpts {
|
||||
catalog?: string[];
|
||||
icons?: string[];
|
||||
"paa-root"?: string[];
|
||||
screenshots?: string[];
|
||||
out?: string[];
|
||||
hemtt?: string[];
|
||||
approve?: boolean;
|
||||
"dry-run"?: boolean;
|
||||
limit?: string[];
|
||||
}
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--approve") { opts.approve = true; continue; }
|
||||
if (a === "--dry-run") { opts["dry-run"] = true; continue; }
|
||||
if (a.startsWith("--") && i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
||||
const key = a.slice(2) as keyof CliOpts;
|
||||
(opts[key] ??= []).push(argv[++i]);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
function resolveHemtt(explicit?: string): string {
|
||||
if (explicit && existsSync(explicit)) return explicit;
|
||||
const probe = spawnSync("which", ["hemtt"], { encoding: "utf8" });
|
||||
if (probe.status === 0 && probe.stdout.trim()) return probe.stdout.trim();
|
||||
throw new Error("hemtt not found; pass --hemtt <path>");
|
||||
}
|
||||
|
||||
const KNOWN_ASSET_TYPES = new Set([
|
||||
"weapon", "magazine", "ammo", "backpack", "vest", "helmet", "uniform",
|
||||
"item", "attachment", "optics", "muzzle", "grip", "rail", "explosive",
|
||||
"grenade", "medical", "tool",
|
||||
]);
|
||||
function classifyAssetType(clazz: string, type: string): string {
|
||||
const t = (type || "").toLowerCase();
|
||||
if (KNOWN_ASSET_TYPES.has(t)) return t;
|
||||
const c = clazz.toLowerCase();
|
||||
if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine";
|
||||
if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon";
|
||||
if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine";
|
||||
return "item";
|
||||
}
|
||||
|
||||
function isVehicle(clazz: string, type: string): boolean {
|
||||
if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true;
|
||||
const c = clazz.toLowerCase();
|
||||
return /^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) &&
|
||||
!/backpack|vest|helmet/.test(c);
|
||||
}
|
||||
|
||||
function lookupIcon(icons: Record<string, unknown>, clazz: string): string {
|
||||
if (icons && typeof icons === "object") {
|
||||
for (const [k, v] of Object.entries(icons)) {
|
||||
if (k.toLowerCase() === clazz.toLowerCase() && typeof v === "string") return v;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolvePaaPath(ref: string, paaRoot: string): string {
|
||||
if (!ref) return "";
|
||||
const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/");
|
||||
const cand = join(paaRoot, clean);
|
||||
return existsSync(cand) ? cand : "";
|
||||
}
|
||||
|
||||
function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined {
|
||||
const base = basename(paaPath).replace(/\.paa$/i, "") + ".png";
|
||||
const dest = join(outDir, base);
|
||||
if (existsSync(dest)) return dest;
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" });
|
||||
return r.status === 0 && existsSync(dest) ? dest : undefined;
|
||||
}
|
||||
function basename(p: string): string {
|
||||
const seg = p.split(/[\\/]/);
|
||||
return seg[seg.length - 1];
|
||||
}
|
||||
|
||||
interface ImportPlan {
|
||||
kind: "asset" | "vehicle";
|
||||
clazz: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
assetType: string;
|
||||
pictureRef: string;
|
||||
paaPath: string;
|
||||
pngPath: string;
|
||||
screenshotPath: string;
|
||||
}
|
||||
|
||||
function planImport(
|
||||
rec: Record<string, unknown>,
|
||||
lookups: { icons: Record<string, unknown>; paaRoot: string; screenshots: string; outDir: string; hemtt: string },
|
||||
): ImportPlan | undefined {
|
||||
const clazz = String(rec.classname ?? rec.class ?? "").trim();
|
||||
const type = String(rec.type ?? rec.category ?? "").trim();
|
||||
if (!clazz) return undefined;
|
||||
const vehicle = isVehicle(clazz, type);
|
||||
const assetType = vehicle ? "vehicle" : classifyAssetType(clazz, type);
|
||||
const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz;
|
||||
const description = String(rec.description ?? "").trim();
|
||||
const pictureRef = String(rec.picture ?? "").trim();
|
||||
const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : "";
|
||||
const ref = iconRef || pictureRef;
|
||||
const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : "";
|
||||
const pngPath = paaPath
|
||||
? convertPaaToPng(lookups.hemtt, paaPath, join(lookups.outDir))
|
||||
: "";
|
||||
const screenshotPath = lookups.screenshots
|
||||
? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "")
|
||||
: "";
|
||||
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, pictureRef: ref, paaPath, pngPath, screenshotPath };
|
||||
}
|
||||
|
||||
async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> {
|
||||
const r = await payload.find({ collection: "resources", limit: 50, where: { name: { equals: "Fuel" } } });
|
||||
const fuel = r.docs.find((d: any) => String(d.name).toLowerCase() === "fuel");
|
||||
if (!fuel) throw new Error("No resource named 'Fuel' found — cannot set vehicle fuel fields");
|
||||
return { id: fuel.id };
|
||||
}
|
||||
|
||||
async function writeEntry(
|
||||
payload: { create: (args: { collection: string; data: any }) => Promise<any>; update: (args: { collection: string; id: number; data: any }) => Promise<any> },
|
||||
plan: ImportPlan,
|
||||
opts: { outDir: string },
|
||||
fuelId: number,
|
||||
): Promise<string> {
|
||||
const common = {
|
||||
class_name: plan.clazz,
|
||||
display_name: plan.displayName,
|
||||
description: plan.description || null,
|
||||
};
|
||||
const mediaId = plan.pngPath || plan.screenshotPath ? await uploadMedia(payload, plan.pngPath || plan.screenshotPath).catch(() => undefined) : undefined;
|
||||
|
||||
if (plan.kind === "vehicle") {
|
||||
return payload.create({
|
||||
collection: "vehicles",
|
||||
data: {
|
||||
...common,
|
||||
vehicleName: plan.displayName,
|
||||
...(mediaId ? { vehicle_metadata_thumbnail: mediaId } : {}),
|
||||
fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 },
|
||||
},
|
||||
}).then((d: any) => `vehicle:${d.id}`);
|
||||
}
|
||||
return payload.create({
|
||||
collection: "assets",
|
||||
data: {
|
||||
...common,
|
||||
assetType: plan.assetType,
|
||||
...(plan.pngPath ? { "Asset Metadata": { mod: "Arma 3", thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
|
||||
},
|
||||
}).then((d: any) => `asset:${d.id}`);
|
||||
}
|
||||
|
||||
async function uploadMedia(payload: { create: (args: { collection: string; data: any; file: any }) => Promise<any> }, filePath: string): Promise<number> {
|
||||
const { readFileSync } = await import("fs");
|
||||
const fileName = basename(filePath);
|
||||
const buffer = readFileSync(filePath);
|
||||
const res = await payload.create({
|
||||
collection: "media",
|
||||
data: { alt: fileName, filename: fileName },
|
||||
file: { data: buffer, mimetype: "image/png", name: fileName, size: buffer.length },
|
||||
});
|
||||
return Number(res.id);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
const hemtt = resolveHemtt(opts.hemtt?.[0]);
|
||||
const catalogPath = opts.catalog?.[0];
|
||||
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required and must exist");
|
||||
const dryRun = !!opts["dry-run"];
|
||||
const approve = !!opts.approve || dryRun;
|
||||
const outDir = opts.out?.[0] ?? dirname(catalogPath);
|
||||
const paaRoot = opts["paa-root"]?.[0] ?? "";
|
||||
const screenshots = opts.screenshots?.[0] ?? "";
|
||||
if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write");
|
||||
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||
const records = Array.isArray(catalog) ? catalog : [catalog];
|
||||
let icons: Record<string, unknown> = {};
|
||||
if (opts.icons?.[0] && existsSync(opts.icons[0])) {
|
||||
icons = JSON.parse(readFileSync(opts.icons[0], "utf8"));
|
||||
}
|
||||
const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length;
|
||||
const lookups = { icons, paaRoot, screenshots, outDir, hemtt };
|
||||
|
||||
console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`);
|
||||
const plans: ImportPlan[] = [];
|
||||
for (const rec of records.slice(0, limit)) {
|
||||
const plan = planImport(rec, lookups);
|
||||
if (!plan) { console.log(` SKIP ${String((rec as any).classname ?? "")} (not an importable record)`); continue; }
|
||||
plans.push(plan);
|
||||
const img = plan.pngPath ? "[img: item-png]" : plan.screenshotPath ? "[img: screenshot]" : "[no img]";
|
||||
console.log(` -> ${plan.kind}\t${plan.clazz}\t${plan.displayName}\t${img}`);
|
||||
}
|
||||
|
||||
if (dryRun) { console.log(`\nDry run: ${plans.length} would import, 0 applied.`); return; }
|
||||
|
||||
const { getPayload } = await import("@payload-config");
|
||||
const payload = await getPayload({ config: (await import("@payload-config")).default });
|
||||
const fuel = await resolveFuelConfig(payload);
|
||||
let ok = 0, err = 0;
|
||||
for (const plan of plans) {
|
||||
try {
|
||||
const id = await writeEntry(payload, plan, { outDir }, fuel.id);
|
||||
console.log(`Applied ${id}`);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
console.error(`ERR ${plan.clazz}: ${(e as Error).message}`);
|
||||
err++;
|
||||
}
|
||||
}
|
||||
console.log(`Done: ${ok} ok, ${err} errors.`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
@ -37,6 +37,7 @@ describe("PreferencesForm", () => {
|
|||
mutedTypes: ["market:offer"],
|
||||
showCallsign: false,
|
||||
showVersionOverlay: true,
|
||||
showBackgroundTexture: true,
|
||||
leadOrActual: "lead",
|
||||
discordEnabled: false,
|
||||
discordMutedTypes: [],
|
||||
|
|
@ -62,6 +63,7 @@ describe("PreferencesForm", () => {
|
|||
mutedTypes: ["market:offer"],
|
||||
showCallsign: true,
|
||||
showVersionOverlay: true,
|
||||
showBackgroundTexture: true,
|
||||
leadOrActual: "actual",
|
||||
discordEnabled: true,
|
||||
discordMutedTypes: ["market:offer"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue