Compare commits

...

15 Commits

Author SHA1 Message Date
Corey Blais be4e7e9a63 chanmged app.ts based on Onyxoasis's recommendations 2026-04-14 14:49:57 -04:00
Corey Blais 40e43c01b1 chanmged app.ts based on Onyxoasis's recommendations 2026-04-14 14:44:13 -04:00
blaisadmin b8ed8b94f2 fixing 12 gauge format 2026-03-29 22:56:21 -04:00
blaisadmin 289dbb324b added additonal 12 gauge types 2026-03-29 22:45:05 -04:00
blaisadmin a0c0d2a9eb remove 0 calibers from graph 2026-03-29 22:36:45 -04:00
blaisadmin 8aaf6f7902 single graph 2026-03-29 22:34:12 -04:00
blaisadmin bf18dcdc7b adding ammo graph 2026-03-29 22:29:10 -04:00
Corey Blais 4bc809ff8b site hardening 2026-03-27 10:04:54 -04:00
Corey Blais 900bf4eb06 Fixing image url 2026-03-26 17:19:01 -04:00
Corey Blais e078e3312b merging category and total counts 2026-03-26 17:16:45 -04:00
Corey Blais de2b63cd87 adding firearm filter and category summary 2026-03-26 17:12:47 -04:00
Corey Blais 8d42df6c27 added favicon 2026-03-26 12:34:58 -04:00
blaisadmin 96d9e3ebd5 updating compose 2026-03-26 11:14:53 -04:00
blaisadmin 266e45f16f Fixing login 2026-03-26 10:49:15 -04:00
blaisadmin 8b7763cf13 Added demo access 2026-03-26 09:59:40 -04:00
16 changed files with 1357 additions and 622 deletions
View File
+4
View File
@@ -5,6 +5,10 @@ NODE_ENV=development
FRONTEND_URL=http://localhost:3000
VITE_API_BASE_URL=http://localhost:5000/api
ALLOW_REGISTRATION=true
ALLOW_DEMO_ACCOUNT=true
DEMO_ACCOUNT_EMAIL=demo@arsenaliq.local
DEMO_ACCOUNT_PASSWORD=demo1234
DEMO_ACCOUNT_NAME=Demo User
# Production-only Traefik settings
TRAEFIK_NETWORK=traefik_proxy
TRAEFIK_ENTRYPOINT=websecure
+12
View File
@@ -103,6 +103,16 @@ The app includes an Express API in [backend/src/app.ts](/home/corey/github/Arsen
- `ALLOW_REGISTRATION=true|false`
- Controls whether `POST /api/auth/register` is available
- When `false`, the login UI hides self-service account creation
- `ALLOW_DEMO_ACCOUNT=true|false`
- Enables a backend-managed demo account and a demo sign-in button on the login page
- `DEMO_ACCOUNT_EMAIL`
- `DEMO_ACCOUNT_PASSWORD`
- `DEMO_ACCOUNT_NAME`
Database note:
- In Docker Compose, the backend uses `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD`
- This avoids malformed `DATABASE_URL` issues when the database password contains URL-sensitive characters
### Response shape notes
@@ -167,6 +177,8 @@ Example:
- Creates a local account when registration is enabled
- `POST /api/auth/login`
- Signs in with local email/password
- `POST /api/auth/demo`
- Signs in to the configured demo account when demo mode is enabled
- `POST /api/auth/logout`
- Invalidates the current session token
- `GET /api/auth/me`
+4 -2
View File
@@ -4,10 +4,12 @@ WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build && npm prune --omit=dev
EXPOSE 5000
CMD ["npm", "run", "dev"]
CMD ["npm", "run", "start"]
+191 -577
View File
File diff suppressed because it is too large Load Diff
+716
View File
@@ -0,0 +1,716 @@
import pg from 'pg';
export type UserRow = {
id: string;
email: string;
name: string;
};
export type ProfileRow = {
id: string;
name: string;
};
export type SessionRow = {
id: string;
user_id: string;
active_profile_id: string | null;
expires_at: string;
};
export type ProviderConfigRow = {
provider_key: string;
display_name: string;
protocol: string;
client_id: string | null;
client_secret: string | null;
authorization_endpoint: string | null;
token_endpoint: string | null;
userinfo_endpoint: string | null;
issuer: string | null;
scopes: string;
enabled: boolean;
};
export type DashboardSummaryRow = {
totalFirearms: number;
totalAmmoRounds: number;
firearmsInvestment: string;
ammoInvestment: string;
configuredCalibers: number;
};
export type FirearmRow = {
id: string;
manufacturer: string;
model: string;
category: string;
caliber: string;
serial_number: string;
purchase_price: string;
acquired_on: string | null;
image_url: string | null;
notes: string | null;
};
export type CaliberRow = {
id: string;
name: string;
is_default: boolean;
is_active: boolean;
};
export type AmmoInventoryRow = {
caliber_id: string;
caliber_name: string;
rounds_on_hand: number;
cost_per_round: string;
};
type SessionWithUserRow = SessionRow & {
email: string;
name: string;
};
type UserWithPasswordRow = UserRow & {
password_hash: string | null;
};
export type AuthProviderUpdate = {
displayName: string;
protocol: string;
clientId: string;
clientSecret: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
issuer: string;
scopes: string;
enabled: boolean;
};
export type FirearmMutation = {
manufacturer: string;
model: string;
category: string;
caliber: string;
serialNumber: string;
purchasePrice: number;
acquiredOn: string | null;
imageUrl: string | null;
notes: string | null;
};
export type ClientConfig = {
databaseUrl: string;
host: string;
port: number;
database: string;
user: string;
password: string;
};
export class ArsenalIqClient {
private pool: pg.Pool;
constructor(config: ClientConfig) {
const { Pool } = pg;
this.pool = config.databaseUrl
? new Pool({ connectionString: config.databaseUrl })
: new Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
});
}
async getNow() {
const result = await this.pool.query<{ now: string }>('SELECT NOW() AS now');
return result.rows[0].now;
}
async ensureSchema() {
await this.pool.query(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, name)
);
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_profile_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS auth_provider_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_key VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
protocol VARCHAR(50) NOT NULL DEFAULT 'oidc',
client_id TEXT,
client_secret TEXT,
authorization_endpoint TEXT,
token_endpoint TEXT,
userinfo_endpoint TEXT,
issuer TEXT,
scopes TEXT NOT NULL DEFAULT 'openid profile email',
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS auth_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE,
provider_subject TEXT NOT NULL,
email VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (provider_key, provider_subject)
);
CREATE TABLE IF NOT EXISTS oauth_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE,
state_code VARCHAR(255) NOT NULL UNIQUE,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS calibers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name VARCHAR(40) NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (profile_id, name)
);
CREATE TABLE IF NOT EXISTS firearms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
manufacturer VARCHAR(120) NOT NULL,
model VARCHAR(120) NOT NULL,
category VARCHAR(80) NOT NULL,
caliber VARCHAR(40) NOT NULL,
serial_number VARCHAR(120) NOT NULL,
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
acquired_on DATE,
image_url TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ammo_inventory (
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
caliber_id UUID NOT NULL REFERENCES calibers(id) ON DELETE CASCADE,
rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0),
cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (profile_id, caliber_id)
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON auth_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_calibers_profile_id ON calibers(profile_id);
CREATE INDEX IF NOT EXISTS idx_firearms_profile_id ON firearms(profile_id);
`);
await this.seedAuthProviders();
}
async ensureProfileDefaults(profileId: string, defaultCalibers: string[]) {
await this.pool.query(
`UPDATE calibers
SET name = '12 Gauge - Sporting',
is_default = TRUE
WHERE profile_id = $1
AND name = '12 Gauge'
AND NOT EXISTS (
SELECT 1
FROM calibers existing
WHERE existing.profile_id = $1
AND existing.name = '12 Gauge - Sporting'
)`,
[profileId],
);
await this.pool.query(
`UPDATE calibers
SET name = '12 Gauge - Sporting',
is_default = TRUE
WHERE profile_id = $1
AND name = '12 Gauge Sporting'
AND NOT EXISTS (
SELECT 1
FROM calibers existing
WHERE existing.profile_id = $1
AND existing.name = '12 Gauge - Sporting'
)`,
[profileId],
);
for (const caliber of defaultCalibers) {
const caliberResult = await this.pool.query<CaliberRow>(
`INSERT INTO calibers (profile_id, name, is_default, is_active)
VALUES ($1, $2, TRUE, TRUE)
ON CONFLICT (profile_id, name) DO UPDATE
SET is_default = TRUE
RETURNING id, name, is_default, is_active`,
[profileId, caliber],
);
await this.ensureAmmoInventory(profileId, caliberResult.rows[0].id);
}
}
async getUserProfiles(userId: string) {
const result = await this.pool.query<ProfileRow>(
'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC',
[userId],
);
return result.rows;
}
async ensureDefaultProfile(userId: string, userName: string, defaultCalibers: string[]) {
const profiles = await this.getUserProfiles(userId);
if (profiles.length > 0) {
await this.ensureProfileDefaults(profiles[0].id, defaultCalibers);
return profiles[0];
}
const created = await this.pool.query<ProfileRow>(
'INSERT INTO profiles (user_id, name) VALUES ($1, $2) RETURNING id, name',
[userId, `${userName.split(' ')[0] || 'Primary'} Arsenal`],
);
await this.ensureProfileDefaults(created.rows[0].id, defaultCalibers);
return created.rows[0];
}
async findUserByEmail(email: string) {
const result = await this.pool.query<UserRow>('SELECT id, email, name FROM users WHERE email = $1', [email]);
return result.rows[0] ?? null;
}
async findUserWithPasswordByEmail(email: string) {
const result = await this.pool.query<UserWithPasswordRow>(
'SELECT id, email, name, password_hash FROM users WHERE email = $1',
[email],
);
return result.rows[0] ?? null;
}
async userExistsByEmail(email: string) {
const result = await this.pool.query<{ id: string }>('SELECT id FROM users WHERE email = $1', [email]);
return (result.rowCount ?? 0) > 0;
}
async updateUserPasswordAndName(userId: string, name: string, passwordHash: string) {
await this.pool.query('UPDATE users SET name = $2, password_hash = $3 WHERE id = $1', [
userId,
name,
passwordHash,
]);
}
async createUser(email: string, passwordHash: string | null, name: string) {
const result = await this.pool.query<UserRow>(
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name',
[email, passwordHash, name],
);
return result.rows[0];
}
async getUserById(userId: string) {
const result = await this.pool.query<UserRow>('SELECT id, email, name FROM users WHERE id = $1', [userId]);
return result.rows[0] ?? null;
}
async createSession(userId: string, activeProfileId: string, tokenHash: string, expiresAtIso: string) {
const result = await this.pool.query<SessionRow>(
`INSERT INTO auth_sessions (user_id, active_profile_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, active_profile_id, expires_at`,
[userId, activeProfileId, tokenHash, expiresAtIso],
);
return result.rows[0];
}
async getSessionByTokenHash(tokenHash: string) {
const result = await this.pool.query<SessionWithUserRow>(
`SELECT s.id, s.user_id, s.active_profile_id, s.expires_at, u.email, u.name
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > NOW()`,
[tokenHash],
);
return result.rows[0] ?? null;
}
async deleteSession(sessionId: string) {
await this.pool.query('DELETE FROM auth_sessions WHERE id = $1', [sessionId]);
}
async setSessionActiveProfile(sessionId: string, profileId: string) {
await this.pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [profileId, sessionId]);
}
async getProfileForUser(profileId: string, userId: string) {
const result = await this.pool.query<ProfileRow>(
'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2',
[profileId, userId],
);
return result.rows[0] ?? null;
}
async getAuthProvider(providerKey: string) {
const result = await this.pool.query<ProviderConfigRow>(
`SELECT provider_key, display_name, protocol, client_id, client_secret,
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
FROM auth_provider_configs
WHERE provider_key = $1`,
[providerKey],
);
return result.rows[0] ?? null;
}
async listEnabledAuthProviders() {
const result = await this.pool.query<ProviderConfigRow>(
`SELECT provider_key, display_name, protocol, client_id, client_secret,
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
FROM auth_provider_configs
WHERE enabled = TRUE
ORDER BY display_name ASC`,
);
return result.rows;
}
async listAuthProviders() {
const result = await this.pool.query<ProviderConfigRow>(
`SELECT provider_key, display_name, protocol, client_id, client_secret,
authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled
FROM auth_provider_configs
ORDER BY display_name ASC`,
);
return result.rows;
}
async updateAuthProvider(providerKey: string, update: AuthProviderUpdate) {
await this.pool.query(
`UPDATE auth_provider_configs
SET display_name = $2,
protocol = $3,
client_id = $4,
client_secret = $5,
authorization_endpoint = $6,
token_endpoint = $7,
userinfo_endpoint = $8,
issuer = $9,
scopes = $10,
enabled = $11,
updated_at = NOW()
WHERE provider_key = $1`,
[
providerKey,
update.displayName,
update.protocol,
update.clientId,
update.clientSecret,
update.authorizationEndpoint,
update.tokenEndpoint,
update.userinfoEndpoint,
update.issuer,
update.scopes,
update.enabled,
],
);
}
async createOauthState(providerKey: string, stateCode: string, redirectUri: string) {
await this.pool.query(
'INSERT INTO oauth_states (provider_key, state_code, redirect_uri) VALUES ($1, $2, $3)',
[providerKey, stateCode, redirectUri],
);
}
async getOauthState(providerKey: string, stateCode: string) {
const result = await this.pool.query<{ redirect_uri: string }>(
'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2',
[providerKey, stateCode],
);
return result.rows[0] ?? null;
}
async deleteOauthState(providerKey: string, stateCode: string) {
await this.pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [
providerKey,
stateCode,
]);
}
async findIdentityUserId(providerKey: string, subject: string) {
const result = await this.pool.query<{ user_id: string }>(
'SELECT user_id FROM auth_identities WHERE provider_key = $1 AND provider_subject = $2',
[providerKey, subject],
);
return result.rows[0]?.user_id ?? null;
}
async createIdentity(userId: string, providerKey: string, subject: string, email: string) {
await this.pool.query(
`INSERT INTO auth_identities (user_id, provider_key, provider_subject, email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider_key, provider_subject) DO NOTHING`,
[userId, providerKey, subject, email],
);
}
async getDashboardSummary(profileId: string) {
const result = await this.pool.query<DashboardSummaryRow>(
`SELECT
(SELECT COUNT(*)::int FROM firearms WHERE profile_id = $1) AS "totalFirearms",
COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory WHERE profile_id = $1), 0) AS "totalAmmoRounds",
COALESCE((SELECT SUM(purchase_price) FROM firearms WHERE profile_id = $1), 0) AS "firearmsInvestment",
COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory WHERE profile_id = $1), 0) AS "ammoInvestment",
(SELECT COUNT(*)::int FROM calibers WHERE profile_id = $1 AND is_active = TRUE) AS "configuredCalibers"`,
[profileId],
);
return result.rows[0];
}
async listFirearms(profileId: string) {
const result = await this.pool.query<FirearmRow>(
`SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
FROM firearms
WHERE profile_id = $1
ORDER BY acquired_on DESC NULLS LAST, created_at DESC`,
[profileId],
);
return result.rows;
}
async createFirearm(profileId: string, firearm: FirearmMutation) {
const result = await this.pool.query<FirearmRow>(
`INSERT INTO firearms (profile_id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`,
[
profileId,
firearm.manufacturer,
firearm.model,
firearm.category,
firearm.caliber,
firearm.serialNumber,
firearm.purchasePrice,
firearm.acquiredOn,
firearm.imageUrl,
firearm.notes,
],
);
return result.rows[0];
}
async updateFirearm(id: string, profileId: string, firearm: FirearmMutation) {
const result = await this.pool.query<FirearmRow>(
`UPDATE firearms
SET manufacturer = $3,
model = $4,
category = $5,
caliber = $6,
serial_number = $7,
purchase_price = $8,
acquired_on = $9,
image_url = $10,
notes = $11,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND profile_id = $2
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`,
[
id,
profileId,
firearm.manufacturer,
firearm.model,
firearm.category,
firearm.caliber,
firearm.serialNumber,
firearm.purchasePrice,
firearm.acquiredOn,
firearm.imageUrl,
firearm.notes,
],
);
return result.rows[0] ?? null;
}
async deleteFirearm(id: string, profileId: string) {
const result = await this.pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [id, profileId]);
return (result.rowCount ?? 0) > 0;
}
async listCalibers(profileId: string) {
const result = await this.pool.query<CaliberRow>(
`SELECT id, name, is_default, is_active
FROM calibers
WHERE profile_id = $1
ORDER BY is_active DESC, is_default DESC, name ASC`,
[profileId],
);
return result.rows;
}
async upsertCaliber(profileId: string, name: string, isDefault: boolean) {
const result = await this.pool.query<CaliberRow>(
`INSERT INTO calibers (profile_id, name, is_default, is_active)
VALUES ($1, $2, $3, TRUE)
ON CONFLICT (profile_id, name) DO UPDATE
SET is_active = TRUE
RETURNING id, name, is_default, is_active`,
[profileId, name, isDefault],
);
return result.rows[0];
}
async updateCaliberActive(id: string, profileId: string, isActive: boolean) {
const result = await this.pool.query<CaliberRow>(
`UPDATE calibers
SET is_active = $3
WHERE id = $1 AND profile_id = $2
RETURNING id, name, is_default, is_active`,
[id, profileId, isActive],
);
return result.rows[0] ?? null;
}
async ensureAmmoInventory(profileId: string, caliberId: string) {
await this.pool.query(
`INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round)
VALUES ($1, $2, 0, 0)
ON CONFLICT (profile_id, caliber_id) DO NOTHING`,
[profileId, caliberId],
);
}
async listAmmoInventory(profileId: string) {
const result = await this.pool.query<AmmoInventoryRow>(
`SELECT ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round
FROM ammo_inventory ai
INNER JOIN calibers c ON c.id = ai.caliber_id
WHERE ai.profile_id = $1 AND c.profile_id = $1 AND c.is_active = TRUE
ORDER BY c.name ASC`,
[profileId],
);
return result.rows;
}
async updateAmmoInventory(profileId: string, caliberId: string, rounds: number, costPerRound: number | null) {
const result = await this.pool.query<AmmoInventoryRow>(
`UPDATE ammo_inventory ai
SET rounds_on_hand = GREATEST(0, ai.rounds_on_hand + $3),
cost_per_round = CASE WHEN $4::numeric IS NULL THEN ai.cost_per_round ELSE $4 END,
updated_at = CURRENT_TIMESTAMP
FROM calibers c
WHERE ai.caliber_id = $2
AND ai.profile_id = $1
AND c.id = ai.caliber_id
AND c.profile_id = $1
RETURNING ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round`,
[profileId, caliberId, rounds, costPerRound],
);
return result.rows[0] ?? null;
}
async close() {
await this.pool.end();
}
private async seedAuthProviders() {
const providers = [
{
providerKey: 'google',
displayName: 'Google',
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
issuer: 'https://accounts.google.com',
},
{
providerKey: 'entra',
displayName: 'Microsoft Entra ID',
authorizationEndpoint: '',
tokenEndpoint: '',
userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo',
issuer: '',
},
{
providerKey: 'oidc',
displayName: 'Custom OIDC',
authorizationEndpoint: '',
tokenEndpoint: '',
userinfoEndpoint: '',
issuer: '',
},
];
for (const provider of providers) {
await this.pool.query(
`INSERT INTO auth_provider_configs
(provider_key, display_name, protocol, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled)
VALUES ($1, $2, 'oidc', $3, $4, $5, $6, 'openid profile email', FALSE)
ON CONFLICT (provider_key) DO NOTHING`,
[
provider.providerKey,
provider.displayName,
provider.authorizationEndpoint,
provider.tokenEndpoint,
provider.userinfoEndpoint,
provider.issuer,
],
);
}
}
}
+27 -26
View File
@@ -15,7 +15,7 @@ services:
timeout: 5s
retries: 10
networks:
- app
- arsenal_iq
backend:
build:
@@ -25,57 +25,58 @@ services:
environment:
PORT: 5000
NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-arsenal_iq}
POSTGRES_USER: ${POSTGRES_USER:-arsenal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arsenal_dev_password}
FRONTEND_URL: ${FRONTEND_URL:-https://arsenal.example.com}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
DEMO_ACCOUNT_EMAIL: ${DEMO_ACCOUNT_EMAIL:-demo@arsenaliq.local}
DEMO_ACCOUNT_PASSWORD: ${DEMO_ACCOUNT_PASSWORD:-demo1234}
DEMO_ACCOUNT_NAME: ${DEMO_ACCOUNT_NAME:-Demo User}
depends_on:
postgres:
condition: service_healthy
command: >
sh -c "npm install --legacy-peer-deps &&
npm run dev"
volumes:
- ./backend:/app
- ./backend/node_modules:/app/node_modules
labels:
- traefik.enable=true
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik}
- traefik.http.routers.arsenaliq-api.rule=Host(`${TRAEFIK_API_HOST:-api.arsenal.local}`)
- traefik.http.routers.arsenaliq-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.arsenaliq-api.tls=true
- traefik.http.services.arsenaliq-api.loadbalancer.server.port=5000
networks:
- app
- traefik_proxy
- arsenal_iq
- traefik
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.arsenal.example.com/api}
VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
VITE_ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
container_name: arsenaliq-frontend
environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.arsenal.example.com/api}
CSP_CONNECT_SRC: ${FRONTEND_CSP_CONNECT_SRC:-https://api.arsenal.example.com}
depends_on:
- backend
command: >
sh -c "npm install --legacy-peer-deps &&
npm run dev -- --host"
volumes:
- ./frontend:/app
- ./frontend/node_modules:/app/node_modules
labels:
- traefik.enable=true
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik}
- traefik.http.routers.arsenaliq-web.rule=Host(`${TRAEFIK_WEB_HOST:-arsenal.local}`)
- traefik.http.routers.arsenaliq-web.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.arsenaliq-web.tls=true
- traefik.http.services.arsenaliq-web.loadbalancer.server.port=3000
- traefik.http.services.arsenaliq-web.loadbalancer.server.port=80
networks:
- app
- traefik_proxy
- arsenal_iq
- traefik
networks:
app:
arsenal_iq:
driver: bridge
traefik_proxy:
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik_proxy}
name: ${TRAEFIK_NETWORK:-traefik}
+26 -7
View File
@@ -9,15 +9,13 @@ services:
volumes:
- ./data/postgres:/var/lib/postgresql/data
- ./backend/database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-arsenal} -d ${POSTGRES_DB:-arsenal_iq}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- app
- arsenal_iq
backend:
build:
@@ -27,9 +25,17 @@ services:
environment:
PORT: 5000
NODE_ENV: ${NODE_ENV:-development}
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-arsenal_iq}
POSTGRES_USER: ${POSTGRES_USER:-arsenal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arsenal_dev_password}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
DEMO_ACCOUNT_EMAIL: ${DEMO_ACCOUNT_EMAIL:-demo@arsenaliq.local}
DEMO_ACCOUNT_PASSWORD: ${DEMO_ACCOUNT_PASSWORD:-demo1234}
DEMO_ACCOUNT_NAME: ${DEMO_ACCOUNT_NAME:-Demo User}
depends_on:
postgres:
condition: service_healthy
@@ -42,7 +48,7 @@ services:
- ./backend:/app
- ./backend/node_modules:/app/node_modules
networks:
- app
- arsenal_iq
frontend:
build:
@@ -52,6 +58,7 @@ services:
environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
VITE_ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
VITE_ALLOW_DEMO_ACCOUNT: ${ALLOW_DEMO_ACCOUNT:-false}
depends_on:
- backend
ports:
@@ -62,9 +69,21 @@ services:
volumes:
- ./frontend:/app
- ./frontend/node_modules:/app/node_modules
labels:
- ${WATCH:-traefik.enable=true}
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
- "traefik.http.routers.${NAME:-arsenaliq}.rule=Host(`${URL:-arsenal.local}`)"
- "traefik.http.routers.${NAME:-arsenaliq}.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.${NAME:-arsenaliq}.tls.certresolver=${TRAEFIK_CERTRESOLVER:-myresolver}"
- "traefik.http.services.${NAME:-arsenaliq}.loadbalancer.server.port=3000"
networks:
- app
- arsenal_iq
- traefik
networks:
app:
arsenal_iq:
driver: bridge
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik}
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL=https://api.arsenal.example.com/api
ARG VITE_ALLOW_REGISTRATION=true
ARG VITE_ALLOW_DEMO_ACCOUNT=false
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_ALLOW_REGISTRATION=${VITE_ALLOW_REGISTRATION}
ENV VITE_ALLOW_DEMO_ACCOUNT=${VITE_ALLOW_DEMO_ACCOUNT}
COPY package*.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arsenal IQ</title>
</head>
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
add_header Cross-Origin-Embedder-Policy "unsafe-none" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src 'self' ${CSP_CONNECT_SRC}; font-src 'self' data:; frame-ancestors 'none'; img-src 'self' data: https:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; form-action 'self'; manifest-src 'self'; upgrade-insecure-requests" always;
location = /robots.txt {
default_type text/plain;
try_files $uri =404;
}
location = /sitemap.xml {
default_type application/xml;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title>Arsenal IQ Crosshairs Icon</title>
<rect width="64" height="64" rx="14" fill="#11150f" />
<circle cx="32" cy="32" r="18" fill="none" stroke="#dce7c2" stroke-width="4" />
<circle cx="32" cy="32" r="4.5" fill="#dce7c2" />
<path
d="M32 8v10M32 46v10M8 32h10M46 32h10"
fill="none"
stroke="#89a05e"
stroke-linecap="round"
stroke-width="4"
/>
<path
d="M32 18v9M32 37v9M18 32h9M37 32h9"
fill="none"
stroke="#dce7c2"
stroke-linecap="round"
stroke-width="4"
/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://arsenal.blaishome.online/sitemap.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://arsenal.blaishome.online/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+81 -1
View File
@@ -232,11 +232,58 @@ textarea {
.view-grid,
.firearm-grid,
.ammo-grid,
.settings-grid {
.settings-grid,
.ammo-chart {
display: grid;
gap: 1rem;
}
.ammo-chart-panel {
margin-bottom: 1.5rem;
padding: 1.1rem 1.2rem 1.25rem;
border-radius: 22px;
border: 1px solid rgba(171, 180, 140, 0.12);
background: linear-gradient(180deg, rgba(38, 46, 30, 0.44), rgba(15, 19, 13, 0.74));
}
.ammo-chart-header h4 {
margin: 0.3rem 0 0;
font-size: 1.1rem;
}
.ammo-chart-row {
display: grid;
gap: 0.55rem;
}
.ammo-chart-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.ammo-chart-meta span {
color: #b8c0af;
font-size: 0.92rem;
}
.ammo-chart-track {
width: 100%;
height: 14px;
border-radius: 999px;
overflow: hidden;
background: rgba(171, 180, 140, 0.08);
border: 1px solid rgba(171, 180, 140, 0.08);
}
.ammo-chart-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #6f8751, #afbf74);
box-shadow: 0 0 22px rgba(126, 151, 82, 0.28);
}
.auth-divider {
margin: 1.5rem 0 1rem;
text-align: center;
@@ -429,6 +476,31 @@ label span {
font-size: 1.65rem;
}
.category-count-list {
display: grid;
gap: 0.55rem;
margin-top: 0.9rem;
padding-top: 0.9rem;
border-top: 1px solid rgba(171, 180, 140, 0.08);
}
.category-count-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
color: #dfe5d3;
}
.category-count-item span {
margin-bottom: 0;
color: #b7bead;
}
.category-count-item strong {
font-size: 1rem;
}
.view-grid {
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.8fr);
}
@@ -524,6 +596,14 @@ label span {
padding: 1rem 0;
}
.filter-control {
min-width: 210px;
}
.filter-control span {
margin-bottom: 0.45rem;
}
.settings-row {
padding: 0.95rem 0;
border-top: 1px solid rgba(171, 180, 140, 0.08);
+202 -9
View File
@@ -110,12 +110,15 @@ type PublicProvider = {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const allowRegistration = (import.meta.env.VITE_ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false';
const allowDemoAccount = (import.meta.env.VITE_ALLOW_DEMO_ACCOUNT ?? 'false').toLowerCase() === 'true';
const tokenStorageKey = 'arsenal-iq-token';
const profileStorageKey = 'arsenal-iq-profile';
const ammoPageSelectionKey = 'arsenal-iq-ammo-page-calibers';
const ammoPageSelectionMigrationKey = 'arsenal-iq-ammo-page-calibers-v2';
const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other'];
const defaultCaliberNames = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
const allFirearmCategoriesLabel = 'All categories';
const defaultCaliberNames = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge - Birdshot', '12 Gauge - Buckshot', '12 Gauge - Slug', '12 Gauge - Sporting', '.45 ACP'];
const boxTrackedCalibers = ['12 Gauge - Sporting'];
const currency = new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -187,6 +190,45 @@ const getCategorySilhouette = (category: string) => {
return silhouettes[normalized] ?? silhouettes.other;
};
const isBoxTrackedCaliber = (caliber: string) => boxTrackedCalibers.includes(caliber);
const getAmmoUnitLabel = (caliber: string, quantity: number) => {
if (isBoxTrackedCaliber(caliber)) {
return quantity === 1 ? 'box' : 'boxes';
}
return quantity === 1 ? 'round' : 'rounds';
};
const getAmmoUnitNoun = (caliber: string) => (isBoxTrackedCaliber(caliber) ? 'boxes' : 'rounds');
const resolveFirearmImageUrl = (imageUrl: string | null | undefined) => {
const trimmed = imageUrl?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.startsWith('//') ? `https:${trimmed}` : trimmed;
try {
const parsed = new URL(normalized);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return null;
}
if (parsed.hostname === 'commons.wikimedia.org' && parsed.pathname.startsWith('/wiki/File:')) {
const fileName = parsed.pathname.replace('/wiki/File:', '');
return `https://commons.wikimedia.org/wiki/Special:FilePath/${fileName}`;
}
return parsed.toString();
} catch {
return null;
}
};
export default function Home() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -203,6 +245,8 @@ export default function Home() {
const [firearmDrafts, setFirearmDrafts] = useState<Record<string, FirearmForm>>({});
const [newFirearm, setNewFirearm] = useState<FirearmForm>(emptyFirearmForm);
const [showNewFirearmForm, setShowNewFirearmForm] = useState(false);
const [selectedFirearmCategory, setSelectedFirearmCategory] = useState(allFirearmCategoriesLabel);
const [failedFirearmImages, setFailedFirearmImages] = useState<Record<string, boolean>>({});
const [ammoAdjustments, setAmmoAdjustments] = useState<AmmoAdjustments>({});
const [ammoPageCaliberIds, setAmmoPageCaliberIds] = useState<string[]>(() => loadStoredAmmoPageSelection());
const [selectedAmmoCaliberId, setSelectedAmmoCaliberId] = useState('');
@@ -267,6 +311,42 @@ export default function Home() {
() => enabledCalibers.filter((caliber) => !ammoPageCaliberIds.includes(caliber.id)),
[ammoPageCaliberIds, enabledCalibers],
);
const ammoChartData = useMemo(() => {
const withRounds = enabledAmmoInventory
.filter((inventory) => inventory.roundsOnHand > 0)
.sort((left, right) => {
if (right.roundsOnHand !== left.roundsOnHand) {
return right.roundsOnHand - left.roundsOnHand;
}
return left.caliber.localeCompare(right.caliber);
});
const maxRounds = withRounds[0]?.roundsOnHand ?? 0;
return withRounds.map((inventory) => ({
...inventory,
widthPercent: maxRounds > 0 ? Math.max((inventory.roundsOnHand / maxRounds) * 100, 8) : 0,
}));
}, [enabledAmmoInventory]);
const firearmCategoryCounts = useMemo(
() =>
firearmCategories.map((category) => ({
category,
count: data?.firearms.filter((firearm) => firearm.category === category).length ?? 0,
})),
[data],
);
const filteredFirearms = useMemo(() => {
if (!data) {
return [];
}
if (selectedFirearmCategory === allFirearmCategoriesLabel) {
return data.firearms;
}
return data.firearms.filter((firearm) => firearm.category === selectedFirearmCategory);
}, [data, selectedFirearmCategory]);
const apiFetch = async <T,>(path: string, init: RequestInit = {}, useProfile = true): Promise<T> => {
const headers = new Headers(init.headers || {});
@@ -309,6 +389,7 @@ export default function Home() {
setFirearmDrafts(
Object.fromEntries(payload.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])),
);
setFailedFirearmImages({});
setAmmoAdjustments(buildAmmoAdjustments(payload.ammoInventory));
};
@@ -533,6 +614,31 @@ export default function Home() {
}
};
const handleDemoLogin = async () => {
setSaving(true);
setError('');
setMessage('');
try {
const payload = await apiFetch<AuthPayload>(
'/auth/demo',
{
method: 'POST',
},
false,
);
window.localStorage.setItem(tokenStorageKey, payload.token);
window.localStorage.setItem(profileStorageKey, payload.activeProfileId);
setToken(payload.token);
setLoginForm({ email: '', password: '' });
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : 'Unable to sign in to demo account');
} finally {
setSaving(false);
}
};
const handleLogout = async () => {
try {
await apiFetch('/auth/logout', { method: 'POST' });
@@ -569,6 +675,18 @@ export default function Home() {
};
const handleFirearmChange = (id: string, field: keyof FirearmForm, value: string) => {
if (field === 'imageUrl') {
setFailedFirearmImages((current) => {
if (!current[id]) {
return current;
}
const next = { ...current };
delete next[id];
return next;
});
}
setFirearmDrafts((current) => ({
...current,
[id]: {
@@ -858,6 +976,11 @@ export default function Home() {
<button className="primary-button" disabled={saving} onClick={() => void handleLogin()} type="button">
Sign in
</button>
{allowDemoAccount ? (
<button className="secondary-button" disabled={saving} onClick={() => void handleDemoLogin()} type="button">
Use demo account
</button>
) : null}
</div>
) : allowRegistration ? (
<div className="form-stack">
@@ -883,6 +1006,10 @@ export default function Home() {
<p className="muted-copy">Account registration is disabled.</p>
) : null}
{allowDemoAccount ? (
<p className="muted-copy">Demo mode is enabled for quick access.</p>
) : null}
<div className="auth-divider">
<span>Single sign-on</span>
</div>
@@ -931,9 +1058,19 @@ export default function Home() {
<div className="mini-stat">
<span>{activeView === 'ammo' ? 'Ammo types' : 'Firearms'}</span>
<strong>{activeView === 'ammo' ? ammoTypesWithRounds : data.summary.totalFirearms}</strong>
{activeView === 'firearms' ? (
<div className="category-count-list" aria-label="Firearm counts by category">
{firearmCategoryCounts.map((item) => (
<div className="category-count-item" key={item.category}>
<span>{item.category}</span>
<strong>{item.count}</strong>
</div>
))}
</div>
) : null}
</div>
<div className="mini-stat">
<span>{activeView === 'ammo' ? 'Total rounds' : 'Total firearm value'}</span>
<span>{activeView === 'ammo' ? 'Total ammo units' : 'Total firearm value'}</span>
<strong>
{activeView === 'ammo'
? data.summary.totalAmmoRounds
@@ -954,22 +1091,48 @@ export default function Home() {
<span className="panel-kicker">Registry</span>
<h3>Existing firearms</h3>
</div>
<label className="filter-control">
<span>Category filter</span>
<select value={selectedFirearmCategory} onChange={(event) => setSelectedFirearmCategory(event.target.value)}>
<option value={allFirearmCategoriesLabel}>
{allFirearmCategoriesLabel} ({data.summary.totalFirearms})
</option>
{firearmCategoryCounts.map((item) => (
<option key={item.category} value={item.category}>
{item.category} ({item.count})
</option>
))}
</select>
</label>
</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>
) : filteredFirearms.length === 0 ? (
<p className="placeholder-copy">No firearms match the selected category.</p>
) : (
data.firearms.map((firearm) => {
filteredFirearms.map((firearm) => {
const draft = firearmDrafts[firearm.id] ?? buildFirearmForm(firearm);
const resolvedImageUrl = resolveFirearmImageUrl(draft.imageUrl);
const firearmImageSrc = !failedFirearmImages[firearm.id] && resolvedImageUrl
? resolvedImageUrl
: getCategorySilhouette(draft.category);
return (
<article className="firearm-card" key={firearm.id}>
<div className="firearm-visual">
<img
className={draft.imageUrl ? 'firearm-photo' : 'firearm-silhouette'}
className={!failedFirearmImages[firearm.id] && resolvedImageUrl ? 'firearm-photo' : 'firearm-silhouette'}
alt={`${firearm.manufacturer} ${firearm.model}`}
src={draft.imageUrl || getCategorySilhouette(draft.category)}
loading="lazy"
referrerPolicy="no-referrer"
onError={() =>
setFailedFirearmImages((current) =>
current[firearm.id] ? current : { ...current, [firearm.id]: true },
)
}
src={firearmImageSrc}
/>
</div>
<div className="form-grid">
@@ -1138,22 +1301,52 @@ export default function Home() {
</div>
</div>
<div className="ammo-chart-panel">
<div className="ammo-chart-header">
<div>
<span className="panel-kicker">Caliber Breakdown</span>
<h4>Rounds by caliber</h4>
</div>
</div>
{ammoChartData.length === 0 ? (
<p className="placeholder-copy">Add ammo to a caliber to see how your inventory stacks up.</p>
) : (
<div className="ammo-chart">
{ammoChartData.map((inventory) => (
<div className="ammo-chart-row" key={inventory.caliberId}>
<div className="ammo-chart-meta">
<strong>{inventory.caliber}</strong>
<span>{inventory.roundsOnHand.toLocaleString()} {getAmmoUnitLabel(inventory.caliber, inventory.roundsOnHand)}</span>
</div>
<div className="ammo-chart-track" aria-hidden="true">
<div
className="ammo-chart-bar"
style={{ width: `${inventory.widthPercent}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="ammo-grid">
{ammoPageInventory.length === 0 ? (
<p className="placeholder-copy">Add an enabled caliber to start tracking rounds on this page.</p>
<p className="placeholder-copy">Add an enabled caliber to start tracking ammo on this page.</p>
) : (
ammoPageInventory.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>
<p>{inventory.roundsOnHand} {getAmmoUnitLabel(inventory.caliber, inventory.roundsOnHand)} on hand</p>
</div>
</div>
<div className="form-grid compact">
<label>
<span>Round adjustment</span>
<span>{isBoxTrackedCaliber(inventory.caliber) ? 'Box adjustment' : 'Round adjustment'}</span>
<input
type="number"
value={ammoAdjustments[inventory.caliberId]?.rounds ?? ''}
@@ -1172,7 +1365,7 @@ export default function Home() {
</div>
<div className="card-footer">
<span>Enter a quantity, then update or remove rounds for this caliber.</span>
<span>Enter a quantity, then update or remove {getAmmoUnitNoun(inventory.caliber)} for this caliber.</span>
<div className="button-row">
<button className="secondary-button" onClick={() => void adjustAmmo(inventory.caliberId, 'remove')} type="button">
Remove ammo