feat: add items

This commit is contained in:
2026-06-18 22:11:55 -03:00
parent 1fd36a39fd
commit 3b4817a8e1
7 changed files with 685 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState, useMemo } from 'react';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Items.module.css';
import QuantityCard from '../../components/QuantityCard';
import { Item, ItemType, ITEM_TYPE_LABELS, getItemType, getItemIconUrl, randInRange } from './types';
type Props = {
items: Item[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
};
export default function ItemGrid({ items, quantities, randMin, randMax, onSetQty }: Props) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ItemType>('all');
const filtered = useMemo(() => {
return items.filter(item => {
if (typeFilter !== 'all' && getItemType(item.id) !== typeFilter) return false;
if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [items, typeFilter, search]);
const handleGive100Visible = () => filtered.forEach(i => onSetQty(i.id, 100));
const handleRandVisible = () => filtered.forEach(i => onSetQty(i.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach(i => onSetQty(i.id, 0));
const activeCount = Object.values(quantities).filter(q => q > 0).length;
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search items..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
{(Object.keys(ITEM_TYPE_LABELS) as ItemType[]).map(t => (
<button
key={t}
className={`${shared.roleBtn} ${typeFilter === t ? shared.roleBtnActive : ''}`}
onClick={() => setTypeFilter(t)}
>
{ITEM_TYPE_LABELS[t]}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No items match</div>
) : (
<div className={styles.grid}>
{filtered.map(item => (
<QuantityCard
key={item.id}
id={item.id}
name={item.name}
iconUrl={getItemIconUrl(item.iconFilePath)}
qty={quantities[item.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
+98
View File
@@ -0,0 +1,98 @@
'use client';
import { useState, useMemo } from 'react';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Items.module.css';
import QuantityCard from '../../components/QuantityCard';
import { Offering, OfferingRole, getOfferingIconUrl, randInRange } from './types';
type Props = {
offerings: Offering[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
};
const ROLE_LABELS: Record<OfferingRole, string> = {
all: 'All', shared: 'Shared', killer: 'Killer', survivor: 'Survivor',
};
const roleMatches = (offeringRole: number, filter: OfferingRole) => {
if (filter === 'all') return true;
if (filter === 'shared') return offeringRole === 0;
if (filter === 'killer') return offeringRole === 1;
if (filter === 'survivor') return offeringRole === 2;
return true;
};
export default function OfferingGrid({ offerings, quantities, randMin, randMax, onSetQty }: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<OfferingRole>('all');
const filtered = useMemo(() => {
return offerings.filter(o => {
if (!roleMatches(o.role, roleFilter)) return false;
if (search.trim() && !o.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [offerings, roleFilter, search]);
const handleGive100Visible = () => filtered.forEach(o => onSetQty(o.id, 100));
const handleRandVisible = () => filtered.forEach(o => onSetQty(o.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach(o => onSetQty(o.id, 0));
const activeCount = Object.values(quantities).filter(q => q > 0).length;
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search offerings..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
{(Object.keys(ROLE_LABELS) as OfferingRole[]).map(r => (
<button
key={r}
className={`${shared.roleBtn} ${roleFilter === r ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter(r)}
>
{ROLE_LABELS[r]}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No offerings match</div>
) : (
<div className={styles.grid}>
{filtered.map(offering => (
<QuantityCard
key={offering.id}
id={offering.id}
name={offering.name}
iconUrl={getOfferingIconUrl(offering.iconFilePath)}
qty={quantities[offering.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import { useState, useEffect } from 'react';
import { useInventoryStore } from '@/store/useInventoryStore';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Items.module.css';
import { Item, Offering } from './types';
import ItemGrid from './ItemGrid';
import OfferingGrid from './OfferingGrid';
type Tab = 'items' | 'offerings';
export default function ItemsPage() {
const store = useInventoryStore();
const [tab, setTab] = useState<Tab>('items');
const [items, setItems] = useState<Item[]>([]);
const [offerings, setOfferings] = useState<Offering[]>([]);
const [randMin, setRandMin] = useState(50);
const [randMax, setRandMax] = useState(200);
useEffect(() => {
Promise.all([
fetch('/data/items.json').then(r => r.json()).catch(() => []),
fetch('/data/offerings.json').then(r => r.json()).catch(() => []),
]).then(([i, o]) => {
setItems(i);
setOfferings(o);
});
}, []);
const handleClearAll = () => {
store.clearCategory('items');
store.clearCategory('offerings');
};
const activeItems = Object.values(store.items).filter(q => q > 0).length;
const activeOfferings = Object.values(store.offerings).filter(q => q > 0).length;
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>Items & Offerings</h1>
<p className={shared.subtitle}>
{activeItems} active items · {activeOfferings} active offerings
</p>
</div>
<div className={styles.rangeGroup}>
<span className={styles.rangeLabel}>Rand range</span>
<input
className={styles.rangeInput}
type="number"
value={randMin}
min={0}
max={5000}
onChange={e => setRandMin(Math.max(0, parseInt(e.target.value) || 0))}
/>
<span className={styles.rangeSep}></span>
<input
className={styles.rangeInput}
type="number"
value={randMax}
min={0}
max={5000}
onChange={e => setRandMax(Math.min(5000, parseInt(e.target.value) || 0))}
/>
</div>
<button className={shared.clearBtn} onClick={handleClearAll}>Clear All</button>
</header>
<div className={styles.tabs}>
{(['items', 'offerings'] as Tab[]).map(t => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
onClick={() => setTab(t)}
>
{t}
</button>
))}
</div>
{tab === 'items' && (
<ItemGrid
items={items}
quantities={store.items}
randMin={randMin}
randMax={randMax}
onSetQty={store.setItemQuantity}
/>
)}
{tab === 'offerings' && (
<OfferingGrid
offerings={offerings}
quantities={store.offerings}
randMin={randMin}
randMax={randMax}
onSetQty={store.setOfferingQuantity}
/>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
export type Item = {
id: string;
name: string;
iconFilePath: string;
};
export type Offering = {
id: string;
name: string;
iconFilePath: string;
role: number;
};
export type ItemType = 'all' | 'toolbox' | 'flashlight' | 'medkit' | 'key' | 'map' | 'other';
export type OfferingRole = 'all' | 'shared' | 'killer' | 'survivor';
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
all: 'All', toolbox: 'Toolbox', flashlight: 'Flashlight',
medkit: 'Med-Kit', key: 'Key', map: 'Map', other: 'Other',
};
export const getItemType = (id: string): ItemType => {
if (/toolbox/i.test(id)) return 'toolbox';
if (/flashlight/i.test(id)) return 'flashlight';
if (/medkit|med_?kit/i.test(id)) return 'medkit';
if (/key/i.test(id)) return 'key';
if (/map/i.test(id)) return 'map';
return 'other';
};
export const getItemIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `/icons/item-icons/${file}.png`;
};
export const getOfferingIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `/icons/offering-icons/${file}.png`;
};
export const randInRange = (min: number, max: number) => {
const lo = Math.min(min, max);
const hi = Math.max(min, max);
return Math.floor(Math.random() * (hi - lo + 1)) + lo;
};