MVP
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import Home from './pages/Home';
|
||||
|
||||
function App() {
|
||||
return <Home />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 80" role="img" aria-label="AR-pattern rifle silhouette">
|
||||
<rect width="160" height="80" rx="16" fill="#375544"/>
|
||||
<g fill="#fff8ef">
|
||||
<rect x="18" y="37" width="59" height="7" rx="2"/>
|
||||
<rect x="67" y="34" width="17" height="8" rx="1.5"/>
|
||||
<rect x="81" y="31" width="31" height="6" rx="1.5"/>
|
||||
<rect x="107" y="33" width="22" height="4" rx="1.5"/>
|
||||
<rect x="118" y="31" width="20" height="3" rx="1.5"/>
|
||||
<rect x="71" y="24" width="12" height="10" rx="1.5"/>
|
||||
<rect x="60" y="24" width="12" height="3" rx="1.5"/>
|
||||
<path d="M56 44h14l6 21h-9l-4-15h-7z"/>
|
||||
<path d="M45 44h10l2 15h-6l-2-9h-3z"/>
|
||||
<path d="M30 37l12-10h16l-9 10z"/>
|
||||
<path d="M16 39l13-4v11l-13-4z"/>
|
||||
<path d="M96 37h16l3 6h-19z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" role="img" aria-label="Generic firearm placeholder">
|
||||
<rect width="120" height="80" rx="16" fill="#555"/>
|
||||
<circle cx="60" cy="30" r="14" stroke="#fff8ef" stroke-width="6" fill="none"/>
|
||||
<path d="M60 14v32M44 30h32" stroke="#fff8ef" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 80" role="img" aria-label="Handgun silhouette">
|
||||
<rect width="140" height="80" rx="16" fill="#5f4630"/>
|
||||
<g fill="#fff8ef">
|
||||
<path d="M24 43h42l9-6h20v-8l-11-4H66l-9-9H35l-11 11z"/>
|
||||
<path d="M39 43h16l2 22c0 2-2 4-4 4h-6c-2 0-4-2-4-4z"/>
|
||||
<rect x="24" y="40" width="15" height="4" rx="2"/>
|
||||
<rect x="73" y="23" width="21" height="3" rx="1.5"/>
|
||||
<path d="M47 43h7l2 16h-4l-1-8h-2l-1 8h-4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" role="img" aria-label="Subgun silhouette">
|
||||
<rect width="120" height="80" rx="16" fill="#4d3f67"/>
|
||||
<path fill="#fff8ef" d="M17 37h45l8-8h16v5l-7 4H68l-5 5H49l-4 13h-6l3-13H29l-7-4H17z"/>
|
||||
<rect x="49" y="23" width="14" height="7" rx="1.5" fill="#fff8ef"/>
|
||||
<path fill="#fff8ef" d="M40 43h8l2 14h-6z"/>
|
||||
<path d="M30 30c3-6 7-8 12-8" stroke="#fff8ef" stroke-width="3" stroke-linecap="round" fill="none"/>
|
||||
<path fill="#ead9bf" d="M48 31h8v10h-8z" opacity=".35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 537 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" role="img" aria-label="Pump shotgun silhouette">
|
||||
<rect width="120" height="80" rx="16" fill="#35576b"/>
|
||||
<path fill="#fff8ef" d="M10 37h76l11-4h11v3l-9 3H88l-9 6H61l-8 15h-6l4-15H10z"/>
|
||||
<rect x="54" y="24" width="16" height="5" rx="1.5" fill="#fff8ef"/>
|
||||
<rect x="72" y="33" width="18" height="2.5" rx="1.25" fill="#fff8ef"/>
|
||||
<path fill="#ead9bf" d="M49 45h8l-2 9h-3z" opacity=".35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,430 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1216;
|
||||
--panel: rgba(20, 28, 34, 0.92);
|
||||
--panel-soft: rgba(28, 38, 45, 0.84);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--text: #edf3ef;
|
||||
--muted: #97a8a5;
|
||||
--gold: #d8b36a;
|
||||
--accent: #78b8a4;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(216, 179, 106, 0.16), transparent 22%),
|
||||
radial-gradient(circle at bottom right, rgba(120, 184, 164, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, #11181d 0%, #0a0f12 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.eyebrow,
|
||||
.panel-kicker {
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
select {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.03)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%23d8b36a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
|
||||
no-repeat right 14px center;
|
||||
padding-right: 42px;
|
||||
}
|
||||
|
||||
select option {
|
||||
color: var(--text);
|
||||
background: #162027;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 290px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
width: min(1440px, calc(100% - 28px));
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 36px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.panel,
|
||||
.summary-card,
|
||||
.error-banner {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.brand-block h1,
|
||||
.stage-header h2 {
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.brand-block p,
|
||||
.summary-card p,
|
||||
.placeholder-copy,
|
||||
.settings-row p,
|
||||
.ammo-card p,
|
||||
.firearm-card p,
|
||||
.mini-stat span,
|
||||
.card-footer span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.panel-kicker {
|
||||
color: var(--gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.nav-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.chip-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background: linear-gradient(135deg, rgba(216, 179, 106, 0.18), rgba(120, 184, 164, 0.12));
|
||||
border-color: rgba(216, 179, 106, 0.18);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(216, 179, 106, 0.12), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.main-stage {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.stage-stats {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
min-width: 160px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mini-stat strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 22px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.panel-heading,
|
||||
.card-footer,
|
||||
.ammo-card-top,
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.view-grid,
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.view-grid {
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(340px, 0.8fr);
|
||||
}
|
||||
|
||||
.firearm-grid,
|
||||
.ammo-grid,
|
||||
.settings-list,
|
||||
.chip-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ammo-toolbar {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.firearm-card,
|
||||
.ammo-card {
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.firearm-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.firearm-visual {
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
aspect-ratio: 16 / 7;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.firearm-visual img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.firearm-photo {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.firearm-silhouette {
|
||||
object-fit: contain;
|
||||
padding: 10px;
|
||||
filter: brightness(0) invert(0.95);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-grid.compact {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
label span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.chip-button {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--gold);
|
||||
color: #16120d;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chip-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.chip-button {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chip-button.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-inline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-inline input {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settings-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(120, 184, 164, 0.14);
|
||||
border: 1px solid rgba(120, 184, 164, 0.18);
|
||||
color: #c9efe4;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(201, 83, 83, 0.16);
|
||||
border-color: rgba(201, 83, 83, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.app-shell,
|
||||
.view-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell {
|
||||
width: min(100% - 16px, 1440px);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.stage-header,
|
||||
.panel-heading,
|
||||
.card-footer,
|
||||
.button-row,
|
||||
.settings-inline,
|
||||
.settings-row,
|
||||
.ammo-card-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stage-stats,
|
||||
.form-grid,
|
||||
.form-grid.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,879 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Boxes, Settings, ShieldCheck } from 'lucide-react';
|
||||
|
||||
type Firearm = {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
caliber: string;
|
||||
serialNumber: string;
|
||||
purchasePrice: number;
|
||||
acquiredOn: string | null;
|
||||
imageUrl: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type Caliber = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type AmmoInventory = {
|
||||
caliberId: string;
|
||||
caliber: string;
|
||||
roundsOnHand: number;
|
||||
costPerRound: number;
|
||||
totalValue: number;
|
||||
};
|
||||
|
||||
type DashboardData = {
|
||||
summary: {
|
||||
totalFirearms: number;
|
||||
totalAmmoRounds: number;
|
||||
firearmsInvestment: number;
|
||||
ammoInvestment: number;
|
||||
configuredCalibers: number;
|
||||
};
|
||||
firearms: Firearm[];
|
||||
calibers: Caliber[];
|
||||
ammoInventory: AmmoInventory[];
|
||||
defaultCalibers: string[];
|
||||
};
|
||||
|
||||
type FirearmForm = {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
caliber: string;
|
||||
serialNumber: string;
|
||||
purchasePrice: string;
|
||||
acquiredOn: string;
|
||||
imageUrl: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type AmmoAdjustments = Record<string, { rounds: string; costPerRound: string }>;
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
||||
const storageKey = 'arsenal-iq-dashboard';
|
||||
|
||||
const currency = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other'];
|
||||
const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
|
||||
|
||||
const fallbackAmmo: AmmoInventory[] = defaultCalibers.map((name) => ({
|
||||
caliberId: `cal-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||
caliber: name,
|
||||
roundsOnHand: 0,
|
||||
costPerRound: 0,
|
||||
totalValue: 0,
|
||||
}));
|
||||
|
||||
const fallbackCalibers: Caliber[] = defaultCalibers.map((name) => ({
|
||||
id: `cal-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||
name,
|
||||
isDefault: true,
|
||||
isActive: fallbackAmmo.some((item) => item.caliber === name) || name === '.308 Win' || name === '.45 ACP',
|
||||
}));
|
||||
|
||||
const computeSummary = (firearms: Firearm[], calibers: Caliber[], ammoInventory: AmmoInventory[]) => {
|
||||
const activeCaliberIds = new Set(
|
||||
calibers.filter((item) => item.isActive).map((item) => item.id),
|
||||
);
|
||||
|
||||
const activeAmmoInventory = ammoInventory.filter((item) => activeCaliberIds.has(item.caliberId));
|
||||
|
||||
return {
|
||||
totalFirearms: firearms.length,
|
||||
totalAmmoRounds: activeAmmoInventory.reduce((sum, item) => sum + item.roundsOnHand, 0),
|
||||
firearmsInvestment: firearms.reduce((sum, item) => sum + item.purchasePrice, 0),
|
||||
ammoInvestment: activeAmmoInventory.reduce((sum, item) => sum + item.totalValue, 0),
|
||||
configuredCalibers: calibers.filter((item) => item.isActive).length,
|
||||
};
|
||||
};
|
||||
|
||||
const buildDashboardData = (
|
||||
firearms: Firearm[] = [],
|
||||
calibers: Caliber[] = fallbackCalibers,
|
||||
ammoInventory: AmmoInventory[] = fallbackAmmo,
|
||||
): DashboardData => ({
|
||||
summary: computeSummary(firearms, calibers, ammoInventory),
|
||||
firearms,
|
||||
calibers,
|
||||
ammoInventory,
|
||||
defaultCalibers,
|
||||
});
|
||||
|
||||
const loadStoredDashboard = (): DashboardData | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizePayload(JSON.parse(raw) as DashboardData);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const emptyFirearmForm: FirearmForm = {
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
category: 'Handgun',
|
||||
caliber: '',
|
||||
serialNumber: '',
|
||||
purchasePrice: '',
|
||||
acquiredOn: '',
|
||||
imageUrl: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
const buildFirearmForm = (firearm?: Firearm): FirearmForm => ({
|
||||
manufacturer: firearm?.manufacturer ?? '',
|
||||
model: firearm?.model ?? '',
|
||||
category: firearm?.category ?? 'Handgun',
|
||||
caliber: firearm?.caliber ?? '',
|
||||
serialNumber: firearm?.serialNumber ?? '',
|
||||
purchasePrice: firearm ? String(firearm.purchasePrice) : '',
|
||||
acquiredOn: firearm?.acquiredOn ?? '',
|
||||
imageUrl: firearm?.imageUrl ?? '',
|
||||
notes: firearm?.notes ?? '',
|
||||
});
|
||||
|
||||
const buildAmmoAdjustments = (inventory: AmmoInventory[]): AmmoAdjustments =>
|
||||
Object.fromEntries(
|
||||
inventory.map((item) => [
|
||||
item.caliberId,
|
||||
{
|
||||
rounds: '',
|
||||
costPerRound: item.costPerRound.toFixed(2),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const normalizePayload = (payload: DashboardData): DashboardData =>
|
||||
buildDashboardData(payload.firearms, payload.calibers, payload.ammoInventory);
|
||||
|
||||
const isLocalId = (id: string) => id.startsWith('local-');
|
||||
|
||||
const getCategorySilhouette = (category: string) => {
|
||||
const normalized = category.toLowerCase();
|
||||
|
||||
const silhouettes: Record<string, string> = {
|
||||
handgun: 'https://commons.wikimedia.org/wiki/Special:FilePath/Glock%2019%20silhouette.svg',
|
||||
rifle: 'https://commons.wikimedia.org/wiki/Special:FilePath/Colt%20M4A1%20silhouette.svg',
|
||||
shotgun: 'https://commons.wikimedia.org/wiki/Special:FilePath/Shotgun.svg',
|
||||
pcc: 'https://commons.wikimedia.org/wiki/Special:FilePath/H%26K%20MP5%20silhouette.svg',
|
||||
other: 'https://commons.wikimedia.org/wiki/Special:FilePath/War%20Cannon.svg',
|
||||
};
|
||||
|
||||
return silhouettes[normalized] ?? silhouettes.other;
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [activeView, setActiveView] = useState<'firearms' | 'ammo' | 'settings'>('firearms');
|
||||
const [data, setData] = useState<DashboardData>(() => loadStoredDashboard() ?? buildDashboardData());
|
||||
const [firearmDrafts, setFirearmDrafts] = useState<Record<string, FirearmForm>>({});
|
||||
const [newFirearm, setNewFirearm] = useState<FirearmForm>(emptyFirearmForm);
|
||||
const [ammoAdjustments, setAmmoAdjustments] = useState<AmmoAdjustments>(() =>
|
||||
buildAmmoAdjustments((loadStoredDashboard() ?? buildDashboardData()).ammoInventory),
|
||||
);
|
||||
const [newCaliber, setNewCaliber] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const activeAmmoInventory = data.ammoInventory.filter((inventory) =>
|
||||
data.calibers.some((caliber) => caliber.id === inventory.caliberId && caliber.isActive),
|
||||
);
|
||||
|
||||
const ammoTypesWithRounds = activeAmmoInventory.filter(
|
||||
(inventory) => inventory.roundsOnHand > 0,
|
||||
).length;
|
||||
|
||||
const applyDashboard = (payload: DashboardData) => {
|
||||
const normalized = normalizePayload(payload);
|
||||
setData(normalized);
|
||||
setFirearmDrafts(
|
||||
Object.fromEntries(normalized.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])),
|
||||
);
|
||||
setAmmoAdjustments(buildAmmoAdjustments(normalized.ammoInventory));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
}, [data]);
|
||||
|
||||
const refreshDashboard = async () => {
|
||||
const response = await fetch(`${apiBaseUrl}/dashboard`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Dashboard request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as DashboardData;
|
||||
applyDashboard(payload);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
await refreshDashboard();
|
||||
} catch {
|
||||
// Keep local fallback ammo/caliber data while API is restarting.
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFirearmChange = (id: string, field: keyof FirearmForm, value: string) => {
|
||||
setFirearmDrafts((current) => ({
|
||||
...current,
|
||||
[id]: {
|
||||
...current[id],
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const saveFirearm = async (id: string) => {
|
||||
const draft = firearmDrafts[id];
|
||||
const updatedFirearm: Firearm = {
|
||||
id,
|
||||
manufacturer: draft.manufacturer,
|
||||
model: draft.model,
|
||||
category: draft.category,
|
||||
caliber: draft.caliber,
|
||||
serialNumber: draft.serialNumber,
|
||||
purchasePrice: Number(draft.purchasePrice || 0),
|
||||
acquiredOn: draft.acquiredOn || null,
|
||||
imageUrl: draft.imageUrl || null,
|
||||
notes: draft.notes || null,
|
||||
};
|
||||
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms.map((firearm) => (firearm.id === id ? updatedFirearm : firearm)),
|
||||
current.calibers,
|
||||
current.ammoInventory,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const endpoint = isLocalId(id) ? `${apiBaseUrl}/firearms` : `${apiBaseUrl}/firearms/${id}`;
|
||||
const method = isLocalId(id) ? 'POST' : 'PUT';
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...draft,
|
||||
purchasePrice: Number(draft.purchasePrice),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (isLocalId(id)) {
|
||||
const savedFirearm = (await response.json()) as Firearm;
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms.map((firearm) => (firearm.id === id ? savedFirearm : firearm)),
|
||||
current.calibers,
|
||||
current.ammoInventory,
|
||||
),
|
||||
);
|
||||
setFirearmDrafts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
next[savedFirearm.id] = buildFirearmForm(savedFirearm);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await refreshDashboard();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic update.
|
||||
}
|
||||
};
|
||||
|
||||
const createFirearm = async () => {
|
||||
const draft = { ...newFirearm };
|
||||
const createdFirearm: Firearm = {
|
||||
id: `local-${Date.now()}`,
|
||||
manufacturer: draft.manufacturer,
|
||||
model: draft.model,
|
||||
category: draft.category,
|
||||
caliber: draft.caliber,
|
||||
serialNumber: draft.serialNumber,
|
||||
purchasePrice: Number(draft.purchasePrice || 0),
|
||||
acquiredOn: draft.acquiredOn || null,
|
||||
imageUrl: draft.imageUrl || null,
|
||||
notes: draft.notes || null,
|
||||
};
|
||||
|
||||
setData((current) =>
|
||||
buildDashboardData([...current.firearms, createdFirearm], current.calibers, current.ammoInventory),
|
||||
);
|
||||
setFirearmDrafts((current) => ({
|
||||
...current,
|
||||
[createdFirearm.id]: buildFirearmForm(createdFirearm),
|
||||
}));
|
||||
setNewFirearm(emptyFirearmForm);
|
||||
setActiveView('firearms');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/firearms`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...draft,
|
||||
purchasePrice: Number(draft.purchasePrice),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const savedFirearm = (await response.json()) as Firearm;
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms.map((firearm) => (firearm.id === createdFirearm.id ? savedFirearm : firearm)),
|
||||
current.calibers,
|
||||
current.ammoInventory,
|
||||
),
|
||||
);
|
||||
setFirearmDrafts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[createdFirearm.id];
|
||||
next[savedFirearm.id] = buildFirearmForm(savedFirearm);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic row visible.
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFirearm = async (id: string) => {
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms.filter((firearm) => firearm.id !== id),
|
||||
current.calibers,
|
||||
current.ammoInventory,
|
||||
),
|
||||
);
|
||||
setFirearmDrafts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isLocalId(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/firearms/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 204) {
|
||||
await refreshDashboard();
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic delete.
|
||||
}
|
||||
};
|
||||
|
||||
const adjustAmmo = async (caliberId: string) => {
|
||||
const adjustment = ammoAdjustments[caliberId];
|
||||
const roundsDelta = Number(adjustment?.rounds || 0);
|
||||
const nextCost = Number(adjustment?.costPerRound || 0);
|
||||
|
||||
setData((current) => {
|
||||
const nextInventory = current.ammoInventory.map((item) => {
|
||||
if (item.caliberId !== caliberId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const roundsOnHand = Math.max(0, item.roundsOnHand + roundsDelta);
|
||||
const costPerRound = nextCost;
|
||||
|
||||
return {
|
||||
...item,
|
||||
roundsOnHand,
|
||||
costPerRound,
|
||||
totalValue: roundsOnHand * costPerRound,
|
||||
};
|
||||
});
|
||||
|
||||
return buildDashboardData(current.firearms, current.calibers, nextInventory);
|
||||
});
|
||||
|
||||
setAmmoAdjustments((current) => ({
|
||||
...current,
|
||||
[caliberId]: {
|
||||
...current[caliberId],
|
||||
rounds: '',
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/ammo/${caliberId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rounds: roundsDelta,
|
||||
costPerRound: nextCost,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await refreshDashboard();
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic update.
|
||||
}
|
||||
};
|
||||
|
||||
const addCaliber = async (name: string) => {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = data.calibers.find((item) => item.name.toLowerCase() === trimmedName.toLowerCase());
|
||||
|
||||
if (!existing) {
|
||||
const caliberId = `local-${trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
const nextCaliber: Caliber = {
|
||||
id: caliberId,
|
||||
name: trimmedName,
|
||||
isDefault: defaultCalibers.includes(trimmedName),
|
||||
isActive: true,
|
||||
};
|
||||
const nextInventory: AmmoInventory = {
|
||||
caliberId,
|
||||
caliber: trimmedName,
|
||||
roundsOnHand: 0,
|
||||
costPerRound: 0,
|
||||
totalValue: 0,
|
||||
};
|
||||
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms,
|
||||
[...current.calibers, nextCaliber],
|
||||
[...current.ammoInventory, nextInventory],
|
||||
),
|
||||
);
|
||||
setAmmoAdjustments((current) => ({
|
||||
...current,
|
||||
[caliberId]: { rounds: '', costPerRound: '0.00' },
|
||||
}));
|
||||
} else {
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms,
|
||||
current.calibers.map((item) =>
|
||||
item.id === existing.id ? { ...item, isActive: true } : item,
|
||||
),
|
||||
current.ammoInventory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setNewCaliber('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/calibers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: trimmedName }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await refreshDashboard();
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic update.
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCaliber = async (caliberId: string, isActive: boolean) => {
|
||||
setData((current) =>
|
||||
buildDashboardData(
|
||||
current.firearms,
|
||||
current.calibers.map((item) => (item.id === caliberId ? { ...item, isActive } : item)),
|
||||
current.ammoInventory,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/calibers/${caliberId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await refreshDashboard();
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic update.
|
||||
}
|
||||
};
|
||||
|
||||
const views = [
|
||||
{ id: 'firearms', label: 'Firearms', icon: ShieldCheck },
|
||||
{ id: 'ammo', label: 'Ammo', icon: Boxes },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<span className="eyebrow">Arsenal IQ</span>
|
||||
<h1>Inventory Control</h1>
|
||||
<p>Track firearms, calibers, and ammo counts cleanly.</p>
|
||||
</div>
|
||||
|
||||
<nav className="nav-stack">
|
||||
{views.map((view) => {
|
||||
const Icon = view.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={activeView === view.id ? 'nav-button active' : 'nav-button'}
|
||||
key={view.id}
|
||||
onClick={() => setActiveView(view.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="main-stage">
|
||||
<header className="stage-header">
|
||||
<div>
|
||||
<span className="eyebrow">Overview</span>
|
||||
<h2>
|
||||
{activeView === 'firearms'
|
||||
? 'Firearms'
|
||||
: activeView === 'ammo'
|
||||
? 'Ammo'
|
||||
: 'Settings'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="stage-stats">
|
||||
<div className="mini-stat">
|
||||
<span>{activeView === 'ammo' ? 'Ammo types' : 'Firearms'}</span>
|
||||
<strong>
|
||||
{activeView === 'ammo'
|
||||
? ammoTypesWithRounds
|
||||
: data.summary.totalFirearms}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<span>{activeView === 'ammo' ? 'Total rounds' : 'Total firearm value'}</span>
|
||||
<strong>
|
||||
{activeView === 'ammo'
|
||||
? data.summary.totalAmmoRounds
|
||||
: currency.format(data.summary.firearmsInvestment)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeView === 'firearms' ? (
|
||||
<section className="view-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">Registry</span>
|
||||
<h3>Existing firearms</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="firearm-grid">
|
||||
{data.firearms.length === 0 ? (
|
||||
<p className="placeholder-copy">No firearms yet. Add your first record from the panel on the right.</p>
|
||||
) : (
|
||||
data.firearms.map((firearm) => {
|
||||
const draft = firearmDrafts[firearm.id] ?? buildFirearmForm(firearm);
|
||||
|
||||
return (
|
||||
<article className="firearm-card" key={firearm.id}>
|
||||
<div className="firearm-visual">
|
||||
<img
|
||||
className={draft.imageUrl ? 'firearm-photo' : 'firearm-silhouette'}
|
||||
alt={`${firearm.manufacturer} ${firearm.model}`}
|
||||
src={draft.imageUrl || getCategorySilhouette(draft.category)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>Manufacturer</span>
|
||||
<input value={draft.manufacturer} onChange={(event) => handleFirearmChange(firearm.id, 'manufacturer', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Model</span>
|
||||
<input value={draft.model} onChange={(event) => handleFirearmChange(firearm.id, 'model', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Category</span>
|
||||
<select value={draft.category} onChange={(event) => handleFirearmChange(firearm.id, 'category', event.target.value)}>
|
||||
{firearmCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Caliber</span>
|
||||
<select value={draft.caliber} onChange={(event) => handleFirearmChange(firearm.id, 'caliber', event.target.value)}>
|
||||
<option value="">Select caliber</option>
|
||||
{data.calibers
|
||||
.filter((caliber) => caliber.isActive)
|
||||
.map((caliber) => (
|
||||
<option key={caliber.id} value={caliber.name}>
|
||||
{caliber.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Serial number</span>
|
||||
<input value={draft.serialNumber} onChange={(event) => handleFirearmChange(firearm.id, 'serialNumber', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Purchase cost</span>
|
||||
<input type="number" step="0.01" value={draft.purchasePrice} onChange={(event) => handleFirearmChange(firearm.id, 'purchasePrice', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Purchase date</span>
|
||||
<input type="date" value={draft.acquiredOn} onChange={(event) => handleFirearmChange(firearm.id, 'acquiredOn', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Image URL</span>
|
||||
<input value={draft.imageUrl} onChange={(event) => handleFirearmChange(firearm.id, 'imageUrl', event.target.value)} />
|
||||
</label>
|
||||
<label className="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea rows={3} value={draft.notes} onChange={(event) => handleFirearmChange(firearm.id, 'notes', event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<span>{draft.acquiredOn ? `Purchased ${draft.acquiredOn}` : 'Purchase date untracked'}</span>
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={() => void deleteFirearm(firearm.id)} type="button">
|
||||
Remove firearm
|
||||
</button>
|
||||
<button className="primary-button" onClick={() => void saveFirearm(firearm.id)} type="button">
|
||||
Save firearm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">Add Record</span>
|
||||
<h3>New firearm</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>Manufacturer</span>
|
||||
<input value={newFirearm.manufacturer} onChange={(event) => setNewFirearm({ ...newFirearm, manufacturer: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Model</span>
|
||||
<input value={newFirearm.model} onChange={(event) => setNewFirearm({ ...newFirearm, model: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Category</span>
|
||||
<select value={newFirearm.category} onChange={(event) => setNewFirearm({ ...newFirearm, category: event.target.value })}>
|
||||
{firearmCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Caliber</span>
|
||||
<select value={newFirearm.caliber} onChange={(event) => setNewFirearm({ ...newFirearm, caliber: event.target.value })}>
|
||||
<option value="">Select caliber</option>
|
||||
{data.calibers
|
||||
.filter((caliber) => caliber.isActive)
|
||||
.map((caliber) => (
|
||||
<option key={caliber.id} value={caliber.name}>
|
||||
{caliber.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Serial number</span>
|
||||
<input value={newFirearm.serialNumber} onChange={(event) => setNewFirearm({ ...newFirearm, serialNumber: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Purchase cost</span>
|
||||
<input type="number" step="0.01" value={newFirearm.purchasePrice} onChange={(event) => setNewFirearm({ ...newFirearm, purchasePrice: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Purchase date</span>
|
||||
<input type="date" value={newFirearm.acquiredOn} onChange={(event) => setNewFirearm({ ...newFirearm, acquiredOn: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Image URL</span>
|
||||
<input value={newFirearm.imageUrl} onChange={(event) => setNewFirearm({ ...newFirearm, imageUrl: event.target.value })} />
|
||||
</label>
|
||||
<label className="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea rows={3} value={newFirearm.notes} onChange={(event) => setNewFirearm({ ...newFirearm, notes: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<button className="primary-button" onClick={() => void createFirearm()} type="button">
|
||||
Add firearm
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeView === 'ammo' ? (
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">Ammo</span>
|
||||
<h3>Configured calibers and round counts</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ammo-grid">
|
||||
{activeAmmoInventory.map((inventory) => (
|
||||
<article className="ammo-card" key={inventory.caliberId}>
|
||||
<div className="ammo-card-top">
|
||||
<div>
|
||||
<strong>{inventory.caliber}</strong>
|
||||
<p>{inventory.roundsOnHand} rounds on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid compact">
|
||||
<label>
|
||||
<span>Add or remove rounds</span>
|
||||
<input
|
||||
type="number"
|
||||
value={ammoAdjustments[inventory.caliberId]?.rounds ?? ''}
|
||||
onChange={(event) =>
|
||||
setAmmoAdjustments((current) => ({
|
||||
...current,
|
||||
[inventory.caliberId]: {
|
||||
...current[inventory.caliberId],
|
||||
rounds: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<span>Positive numbers add rounds. Negative numbers remove them.</span>
|
||||
<button className="primary-button" onClick={() => void adjustAmmo(inventory.caliberId)} type="button">
|
||||
Update count
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeView === 'settings' ? (
|
||||
<section className="settings-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">Defaults</span>
|
||||
<h3>Enable common calibers</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chip-grid">
|
||||
{data.defaultCalibers.map((caliber) => {
|
||||
const configured = data.calibers.some((item) => item.name === caliber && item.isActive);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={configured ? 'chip-button disabled' : 'chip-button'}
|
||||
disabled={configured}
|
||||
key={caliber}
|
||||
onClick={() => void addCaliber(caliber)}
|
||||
type="button"
|
||||
>
|
||||
{configured ? `${caliber} enabled` : `Enable ${caliber}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">Custom</span>
|
||||
<h3>Add a caliber</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-inline">
|
||||
<input placeholder="6.5 Creedmoor" value={newCaliber} onChange={(event) => setNewCaliber(event.target.value)} />
|
||||
<button className="primary-button" onClick={() => void addCaliber(newCaliber)} type="button">
|
||||
Add caliber
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user