From 3b4817a8e125807140a34f864a13c3d7b1e892b8 Mon Sep 17 00:00:00 2001 From: neru Date: Thu, 18 Jun 2026 22:11:55 -0300 Subject: [PATCH] feat: add items --- app/items/ItemGrid.tsx | 86 +++++++++++++++++ app/items/OfferingGrid.tsx | 98 ++++++++++++++++++++ app/items/page.tsx | 110 ++++++++++++++++++++++ app/items/types.ts | 46 +++++++++ components/QuantityCard.tsx | 65 +++++++++++++ styles/Items.module.css | 116 +++++++++++++++++++++++ styles/QuantityCard.module.css | 164 +++++++++++++++++++++++++++++++++ 7 files changed, 685 insertions(+) create mode 100644 app/items/ItemGrid.tsx create mode 100644 app/items/OfferingGrid.tsx create mode 100644 app/items/page.tsx create mode 100644 app/items/types.ts create mode 100644 components/QuantityCard.tsx create mode 100644 styles/Items.module.css create mode 100644 styles/QuantityCard.module.css diff --git a/app/items/ItemGrid.tsx b/app/items/ItemGrid.tsx new file mode 100644 index 0000000..455e772 --- /dev/null +++ b/app/items/ItemGrid.tsx @@ -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; + 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('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 ( + <> +
+ setSearch(e.target.value)} + /> + +
+ {(Object.keys(ITEM_TYPE_LABELS) as ItemType[]).map(t => ( + + ))} +
+ + + {filtered.length} shown · {activeCount} active + + + + +
+ + {filtered.length === 0 ? ( +
No items match
+ ) : ( +
+ {filtered.map(item => ( + + ))} +
+ )} + + ); +} \ No newline at end of file diff --git a/app/items/OfferingGrid.tsx b/app/items/OfferingGrid.tsx new file mode 100644 index 0000000..cef6ef6 --- /dev/null +++ b/app/items/OfferingGrid.tsx @@ -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; + randMin: number; + randMax: number; + onSetQty: (id: string, qty: number) => void; +}; + +const ROLE_LABELS: Record = { + 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('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 ( + <> +
+ setSearch(e.target.value)} + /> + +
+ {(Object.keys(ROLE_LABELS) as OfferingRole[]).map(r => ( + + ))} +
+ + + {filtered.length} shown · {activeCount} active + + + + +
+ + {filtered.length === 0 ? ( +
No offerings match
+ ) : ( +
+ {filtered.map(offering => ( + + ))} +
+ )} + + ); +} \ No newline at end of file diff --git a/app/items/page.tsx b/app/items/page.tsx new file mode 100644 index 0000000..c27eead --- /dev/null +++ b/app/items/page.tsx @@ -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('items'); + const [items, setItems] = useState([]); + const [offerings, setOfferings] = useState([]); + + 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 ( +
+
+
+

Items & Offerings

+

+ {activeItems} active items · {activeOfferings} active offerings +

+
+ +
+ Rand range + setRandMin(Math.max(0, parseInt(e.target.value) || 0))} + /> + + setRandMax(Math.min(5000, parseInt(e.target.value) || 0))} + /> +
+ + +
+ +
+ {(['items', 'offerings'] as Tab[]).map(t => ( + + ))} +
+ + {tab === 'items' && ( + + )} + + {tab === 'offerings' && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/app/items/types.ts b/app/items/types.ts new file mode 100644 index 0000000..d11ef77 --- /dev/null +++ b/app/items/types.ts @@ -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 = { + 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; +}; \ No newline at end of file diff --git a/components/QuantityCard.tsx b/components/QuantityCard.tsx new file mode 100644 index 0000000..ed4f263 --- /dev/null +++ b/components/QuantityCard.tsx @@ -0,0 +1,65 @@ +'use client'; + +import styles from '../styles/QuantityCard.module.css'; +import { randInRange } from '../app/items/types'; + +type Props = { + id: string; + name: string; + iconUrl: string; + qty: number; + randMin: number; + randMax: number; + onSetQty: (id: string, qty: number) => void; +}; + +const clamp = (v: number) => Math.min(32767, Math.max(0, v)); + +export default function QuantityCard({ id, name, iconUrl, qty, randMin, randMax, onSetQty }: Props) { + const active = qty > 0; + + return ( +
+ {name} + {name} + + {active ? ( + <> +
+ + { + const v = parseInt(e.target.value); + if (!isNaN(v)) onSetQty(id, clamp(v)); + }} + /> + +
+ +
+ + + +
+ + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/styles/Items.module.css b/styles/Items.module.css new file mode 100644 index 0000000..d793881 --- /dev/null +++ b/styles/Items.module.css @@ -0,0 +1,116 @@ +/* + tabs +*/ +.tabs { + display: flex; + border-bottom: 1px solid #1a1a1a; + margin-bottom: 1.5rem; + flex-shrink: 0; +} + +.tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + color: #444444; + padding: 0.7rem 1.4rem; + font-family: 'Oswald', sans-serif; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.15s ease; +} + +.tab:hover { color: #888888; } + +.tabActive { + color: #ffffff; + border-bottom-color: #a30000; +} + +/* + range ctl +*/ + +.rangeGroup { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.85rem; + background: #0d0d0d; + border: 1px solid #1a1a1a; +} + +.rangeLabel { + font-family: 'Oswald', sans-serif; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #444444; + white-space: nowrap; +} + +.rangeInput { + background: transparent; + border: none; + border-bottom: 1px solid #2a2a2a; + color: #5599ff; + font-family: 'Oswald', sans-serif; + font-size: 0.8rem; + width: 48px; + text-align: center; + padding: 0.1rem 0; +} + +.rangeInput::-webkit-outer-spin-button, +.rangeInput::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.rangeInput:focus { + outline: none; + border-bottom-color: #5599ff; +} + +.rangeSep { + color: #333333; + font-size: 0.75rem; +} + +/* + randomization btn +*/ +.randBtn { + background: transparent; + border: 1px solid #1a3a88; + color: #3366cc; + padding: 0.6rem 1.25rem; + font-family: 'Oswald', sans-serif; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.2s ease; +} + +.randBtn:hover { + background: #0a1a44; + color: #88aaff; + border-color: #4466cc; + box-shadow: 0 0 12px rgba(50, 80, 200, 0.3); +} + +/* + grid +*/ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.75rem; + overflow-y: auto; + flex: 1; + align-content: start; + padding-right: 0.5rem; +} \ No newline at end of file diff --git a/styles/QuantityCard.module.css b/styles/QuantityCard.module.css new file mode 100644 index 0000000..b74b5fe --- /dev/null +++ b/styles/QuantityCard.module.css @@ -0,0 +1,164 @@ +/* + cards +*/ +.card { + background: #0d0d0d; + border: 1px solid #1a1a1a; + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem 0.6rem 0.75rem; + gap: 0.5rem; + transition: border-color 0.15s ease, background 0.15s ease; + user-select: none; +} + +.cardActive { + border-color: #a30000; + background: #0d0000; +} + +.icon { + width: 52px; + height: 52px; + object-fit: contain; + filter: grayscale(1) brightness(0.3); + transition: filter 0.15s ease; + pointer-events: none; +} + +.cardActive .icon { + filter: none; +} + +.name { + font-size: 0.62rem; + text-align: center; + color: #3a3a3a; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.3; + flex: 1; + display: flex; + align-items: center; +} + +.cardActive .name { + color: #c9c9c9; +} + +/* + quantity rows +*/ +.qtyRow { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #2a2a2a; + background: #060606; +} + +.qtyBtn { + background: transparent; + border: none; + color: #555555; + width: 22px; + cursor: pointer; + font-size: 1rem; + line-height: 1; + transition: all 0.1s ease; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.qtyBtn:hover { + color: #ffffff; + background: #1a0000; +} + +.qtyInput { + flex: 1; + background: transparent; + border: none; + color: #a30000; + font-family: 'Oswald', sans-serif; + font-size: 0.82rem; + text-align: center; + width: 0; + min-width: 0; + padding: 0.3rem 0; +} +.qtyInput::-webkit-outer-spin-button, +.qtyInput::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.qtyInput:focus { + outline: none; +} + +/* + quick actions +*/ +.quickRow { + display: flex; + gap: 0.3rem; + width: 100%; +} + +.quickBtn { + flex: 1; + background: transparent; + border: 1px solid #1e1e1e; + color: #3a3a3a; + padding: 0.25rem 0; + font-family: 'Oswald', sans-serif; + font-size: 0.65rem; + text-transform: uppercase; + cursor: pointer; + transition: all 0.1s ease; + text-align: center; +} + +.quickBtn:hover { + color: #c9c9c9; + border-color: #333333; +} + +.quickBtnRand:hover { + color: #5599ff; + border-color: #1a3a88; + background: #05060d; +} + +.quickBtnRemove:hover { + color: #ff4444; + border-color: #661111; + background: #0d0505; +} + +/* + buttons +*/ +.addBtn { + width: 100%; + background: transparent; + border: 1px solid #1a1a1a; + color: #2e2e2e; + padding: 0.45rem; + font-family: 'Oswald', sans-serif; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + cursor: pointer; + transition: all 0.15s ease; + margin-top: auto; +} + +.addBtn:hover { + color: #c9c9c9; + border-color: #4a0000; + background: #0d0000; +} \ No newline at end of file