This commit is contained in:
blaisadmin
2026-03-25 21:54:50 -04:00
parent a34b585b21
commit 04c74de25a
30 changed files with 6854 additions and 6 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arsenal IQ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2702
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "arsenal-iq-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "0.263.1",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitejs/plugin-react": "4.2.1",
"autoprefixer": "10.4.16",
"postcss": "8.4.32",
"tailwindcss": "3.4.1",
"typescript": "5.3.3",
"vite": "5.0.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+7
View File
@@ -0,0 +1,7 @@
import Home from './pages/Home';
function App() {
return <Home />;
}
export default App;
+17
View File
@@ -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

+5
View File
@@ -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

+10
View File
@@ -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

+8
View File
@@ -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

+7
View File
@@ -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

+430
View File
@@ -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%;
}
}
+10
View File
@@ -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>,
);
+879
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
'/health': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
},
});