From 08b5815b9511e1723e39ddb1870f9e9aa092313c Mon Sep 17 00:00:00 2001 From: neru Date: Thu, 18 Jun 2026 21:05:19 -0300 Subject: [PATCH] feat: add characters tab --- app/characters/page.tsx | 142 +++++++++++++++++ styles/Characters.module.css | 290 +++++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 app/characters/page.tsx create mode 100644 styles/Characters.module.css diff --git a/app/characters/page.tsx b/app/characters/page.tsx new file mode 100644 index 0000000..283bf59 --- /dev/null +++ b/app/characters/page.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useInventoryStore } from '@/store/useInventoryStore'; +import styles from '../../styles/Characters.module.css'; + +type Character = { + idx: number; + name: string; + iconFilePath: string; +}; + +const getIconUrl = (iconFilePath: string) => { + const fileName = iconFilePath.split('/').pop()?.split('.')[0]; + return `/icons/character-icons/${fileName}.png`; +}; + +type RoleFilter = 'all' | 'survivors' | 'killers'; + +const isKiller = (idx: number) => idx >= 268435456; + +export default function CharactersPage() { + const store = useInventoryStore(); + + const [characters, setCharacters] = useState([]); + const [search, setSearch] = useState(''); + const [role, setRole] = useState('all'); + + useEffect(() => { + fetch('/data/characters.json') + .then(r => r.json()) + .then(setCharacters) + .catch(() => []); + }, []); + + const filtered = useMemo(() => { + return characters.filter(c => { + if (role === 'survivors' && isKiller(c.idx)) return false; + if (role === 'killers' && !isKiller(c.idx)) return false; + if (search.trim() && !c.name.toLowerCase().includes(search.toLowerCase())) return false; + return true; + }); + }, [characters, search, role]); + + const handleToggle = (idx: number) => { + store.toggleItem(idx.toString(), 'characters'); + }; + + const handleUnlockAll = () => { + const ids = filtered.map(c => c.idx.toString()); + const outside = store.unlockedCharacters.filter( + id => !filtered.some(c => c.idx.toString() === id) + ); + store.unlockAllInCategory('characters', [...outside, ...ids]); + }; + + const handleLockAll = () => { + const ids = filtered.map(c => c.idx.toString()); + const newUnlocked = store.unlockedCharacters.filter(id => !ids.includes(id)); + store.unlockAllInCategory('characters', newUnlocked); + }; + + const handleClear = () => { + store.clearCategory('characters'); + }; + const unlockedCount = store.unlockedCharacters.length; + + return (
+
+
+

Characters

+

{unlockedCount} of {characters.length} unlocked

+
+
+ +
+ setSearch(e.target.value)} + /> + +
+ {(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => ( + + ))} +
+ + + + {filtered.length} shown + + + + + +
+ + {filtered.length === 0 ? ( +
No characters match
+ ) : ( +
+ {filtered.map(char => { + const unlocked = store.unlockedCharacters.includes(char.idx.toString()); + const killer = isKiller(char.idx); + return ( +
handleToggle(char.idx)} + > + {char.name} + {char.name} + + {killer ? 'Killer' : 'Survivor'} + +
+ ); + })} +
+ )} +
) +} \ No newline at end of file diff --git a/styles/Characters.module.css b/styles/Characters.module.css new file mode 100644 index 0000000..13806d9 --- /dev/null +++ b/styles/Characters.module.css @@ -0,0 +1,290 @@ +.container { + padding: 3rem 4rem; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* + header +*/ +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid #1a1a1a; + flex-shrink: 0; +} + +.title { + font-size: 2.25rem; + text-transform: uppercase; + color: #ffffff; + letter-spacing: 0.05em; + margin: 0 0 0.25rem 0; + text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8); +} + +.subtitle { + font-size: 0.8rem; + color: #555555; + text-transform: uppercase; + letter-spacing: 0.1em; + margin: 0; +} + +/* + toolbar +*/ +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + flex-shrink: 0; + flex-wrap: wrap; +} + +.searchInput { + background: #0d0d0d; + border: 1px solid #1a1a1a; + color: #c9c9c9; + padding: 0.6rem 1rem; + font-family: 'Roboto Condensed', sans-serif; + font-size: 0.85rem; + width: 240px; + transition: border-color 0.15s ease; +} + +.searchInput::placeholder { + color: #444444; +} + +.searchInput:focus { + outline: none; + border-color: #a30000; +} + +.roleFilter { + display: flex; + gap: 0; + border: 1px solid #1a1a1a; +} + +.roleBtn { + background: transparent; + border: none; + border-right: 1px solid #1a1a1a; + color: #555555; + padding: 0.6rem 1.1rem; + font-family: 'Oswald', sans-serif; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.15s ease; +} + +.roleBtn:last-child { + border-right: none; +} + +.roleBtn:hover { + color: #ffffff; + background: #1a0000; +} + +.roleBtnActive { + background: #1a0000; + color: #ffffff; + border-color: #a30000; + box-shadow: inset 3px 0 0 #a30000; +} + +.spacer { + flex: 1; +} + +.unlockAllBtn { + background: transparent; + border: 1px solid #4a0000; + color: #a30000; + 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; +} + +.unlockAllBtn:hover { + background: #a30000; + color: #ffffff; + border-color: #ff0000; + box-shadow: 0 0 12px rgba(163, 0, 0, 0.4); +} + +.lockAllBtn { + background: transparent; + border: 1px solid #3d3d3d; + color: #dddddd; + 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; +} +.lockAllBtn:hover { + background: #000000; + color: #ffffff; + border-color: #303030; + box-shadow: 0 0 12px rgba(165, 165, 165, 0.4); +} + +.clearBtn { + background: transparent; + border: 1px solid #1a1a1a; + color: #444444; + 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.15s ease; +} + +.clearBtn:hover { + color: #888888; + border-color: #333333; +} + +.resultCount { + font-size: 0.75rem; + color: #444444; + text-transform: uppercase; + letter-spacing: 0.08em; + font-family: 'Oswald', sans-serif; +} + +/* + char stuff +*/ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 1rem; + overflow-y: auto; + flex: 1; + align-content: start; + padding-right: 0.5rem; +} + +.card { + background: #0d0d0d; + border: 1px solid #1a1a1a; + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem 0.75rem 0.75rem; + gap: 0.5rem; + cursor: pointer; + transition: all 0.15s ease; + position: relative; + user-select: none; +} + +.card:hover { + border-color: #333333; + background: #111111; +} + +.cardUnlocked { + border-color: #a30000; + background: #0d0000; +} + +.cardUnlocked:hover { + border-color: #cc0000; + background: #110000; + box-shadow: 0 0 16px rgba(163, 0, 0, 0.2); +} + +.cardIcon { + width: 72px; + height: 72px; + object-fit: contain; + filter: grayscale(1) brightness(0.4); + transition: filter 0.15s ease; +} + +.cardUnlocked .cardIcon { + filter: none; +} + +.card:hover .cardIcon { + filter: grayscale(0.3) brightness(0.7); +} + +.cardUnlocked:hover .cardIcon { + filter: none; +} + +.cardName { + font-size: 0.72rem; + text-align: center; + color: #555555; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.3; + transition: color 0.15s ease; +} + +.cardUnlocked .cardName { + color: #c9c9c9; +} + +.card:hover .cardName { + color: #888888; +} + +.cardUnlocked:hover .cardName { + color: #ffffff; +} + +.rolePip { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-family: 'Oswald', sans-serif; + color: #333333; +} + +.rolePipKiller { + color: #5a0000; +} + +.cardUnlocked .rolePip { + color: #444444; +} + +.cardUnlocked .rolePipKiller { + color: #7a0000; +} + +.empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: #333333; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} \ No newline at end of file