feat: add customizations page

This commit is contained in:
2026-06-18 21:54:12 -03:00
parent 7b08860b4e
commit 952de164b9
6 changed files with 787 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
'use client';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Customizations.module.css';
import { CustomizationItem, CATEGORY_LABELS, CATEGORY_ORDER, getCosmeticIconUrl } from './types';
type Props = {
charName: string;
charCosmetics: Record<number, CustomizationItem[]>;
unlockedSet: Set<string>;
characterMap: Map<number, string>;
onBack: () => void;
onUnlockAll: () => void;
onLockAll: () => void;
onToggle: (id: string) => void;
};
export default function CharacterCosmetics({
charName,
charCosmetics,
unlockedSet,
characterMap,
onBack,
onUnlockAll,
onLockAll,
onToggle,
}: Props) {
const allItems = Object.values(charCosmetics).flat();
const unlockedCount = allItems.filter(i => unlockedSet.has(i.id)).length;
return (
<div className={styles.cosmeticsView}>
<div className={styles.cosmeticsHeader}>
<button className={styles.backBtn} onClick={onBack}> Back</button>
<span className={styles.cosmeticsCharName}>{charName}</span>
<span className={shared.resultCount}>{unlockedCount} / {allItems.length} unlocked</span>
<span className={shared.spacer} />
<button className={shared.unlockAllBtn} onClick={onUnlockAll}>Unlock all</button>
<button className={shared.lockAllBtn} onClick={onLockAll}>Lock all</button>
</div>
<div className={styles.cosmeticsBody}>
{CATEGORY_ORDER.filter(cat => charCosmetics[cat]?.length > 0).map(cat => (
<div key={cat} className={styles.categoryGroup}>
<div className={styles.categoryTitle}>
{CATEGORY_LABELS[cat] ?? `Category ${cat}`}
</div>
<div className={styles.gridInline}>
{charCosmetics[cat].map(item => {
const unlocked = unlockedSet.has(item.id);
return (
<div
key={item.id}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => onToggle(item.id)}
>
<img
className={shared.cardIcon}
src={getCosmeticIconUrl(item, characterMap)}
alt={item.name}
loading="lazy"
/>
<span className={shared.cardName}>{item.name}</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
'use client';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Customizations.module.css';
import { Character, RoleFilter } from './types';
type Props = {
filteredChars: Character[];
charSearch: string;
charRole: RoleFilter;
charFullyUnlocked: Map<number, boolean>;
onSelect: (idx: number) => void;
onSearchChange: (s: string) => void;
onRoleChange: (r: RoleFilter) => void;
onUnlockShownCosmetics: () => void;
onLockShownCosmetics: () => void;
};
const getCharIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `/icons/character-icons/${file}.png`;
};
export default function CharacterPicker({
filteredChars,
charSearch,
charRole,
charFullyUnlocked,
onSelect,
onSearchChange,
onRoleChange,
onUnlockShownCosmetics,
onLockShownCosmetics,
}: Props) {
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search character..."
value={charSearch}
onChange={e => onSearchChange(e.target.value)}
/>
<div className={shared.roleFilter}>
{(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => (
<button
key={r}
className={`${shared.roleBtn} ${charRole === r ? shared.roleBtnActive : ''}`}
onClick={() => onRoleChange(r)}
>
{r}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filteredChars.length} shown</span>
<button className={shared.unlockAllBtn} onClick={onUnlockShownCosmetics}>
Unlock Shown Cosmetics
</button>
<button className={shared.lockAllBtn} onClick={onLockShownCosmetics}>
Lock Shown Cosmetics
</button>
</div>
<div className={styles.charGrid}>
{filteredChars.map(char => {
const fullyUnlocked = charFullyUnlocked.get(char.idx) ?? false;
return (
<div
key={char.idx}
className={`${styles.charCard} ${fullyUnlocked ? styles.charCardFullyUnlocked : ''}`}
onClick={() => onSelect(char.idx)}
>
<img
className={styles.charCardIcon}
src={getCharIconUrl(char.iconFilePath)}
alt={char.name}
loading="lazy"
/>
<span className={styles.charCardName}>{char.name}</span>
</div>
);
})}
</div>
</>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Customizations.module.css';
import { CustomizationItem, Tab, getCosmeticIconUrl } from './types';
type Props = {
tab: Tab;
allFilteredItems: CustomizationItem[];
pagedItems: CustomizationItem[];
page: number;
totalPages: number;
search: string;
unlockedSet: Set<string>;
characterMap: Map<number, string>;
onSearchChange: (s: string) => void;
onUnlockAll: () => void;
onLockAll: () => void;
onUnlockPage: () => void;
onLockPage: () => void;
onToggle: (id: string) => void;
onPageChange: (p: number) => void;
};
export default function FlatCategory({
tab,
allFilteredItems,
pagedItems,
page,
totalPages,
search,
unlockedSet,
characterMap,
onSearchChange,
onUnlockAll,
onLockAll,
onUnlockPage,
onLockPage,
onToggle,
onPageChange,
}: Props) {
const buildPageNumbers = () => {
const pages: number[] = [];
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) pages.push(i);
return pages;
};
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder={`Search ${tab}...`}
value={search}
onChange={e => onSearchChange(e.target.value)}
/>
<span className={shared.spacer} />
<span className={shared.resultCount}>{allFilteredItems.length} items</span>
<button className={shared.unlockAllBtn} onClick={onUnlockAll}>
Unlock all ({allFilteredItems.length})
</button>
<button className={shared.lockAllBtn} onClick={onLockAll}>
Lock all
</button>
<button className={styles.pageActionBtn} onClick={onUnlockPage}>
Unlock visible
</button>
<button className={styles.pageActionBtn} onClick={onLockPage}>
Lock visible
</button>
</div>
{allFilteredItems.length === 0 ? (
<div className={shared.empty}>No items found</div>
) : (
<>
<div className={styles.grid}>
{pagedItems.map(item => {
const unlocked = unlockedSet.has(item.id);
return (
<div
key={item.id}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => onToggle(item.id)}
>
<img
className={shared.cardIcon}
src={getCosmeticIconUrl(item, characterMap)}
alt={item.name}
loading="lazy"
/>
<span className={shared.cardName}>{item.name}</span>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className={shared.pagination}>
<button
className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(1)}
>«</button>
<button
className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(page - 1)}
></button>
{buildPageNumbers().map(n => (
<button
key={n}
className={`${shared.pageBtn} ${n === page ? shared.pageBtnActive : ''}`}
onClick={() => onPageChange(n)}
>{n}</button>
))}
<button
className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(page + 1)}
></button>
<button
className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(totalPages)}
>»</button>
<span className={shared.pageInfo}>{page} / {totalPages}</span>
</div>
)}
</>
)}
</>
);
}
+228
View File
@@ -0,0 +1,228 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useInventoryStore } from '@/store/useInventoryStore';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Customizations.module.css';
import { getFileName, cleanFolderName, isKiller } from '../lib/utils';
import { Character, CustomizationItem, RoleFilter, Tab, TAB_CATEGORIES, CATEGORY_ORDER } from './types';
import CharacterPicker from './CharacterPicker';
import CharacterCosmetics from './CharacterCosmetics';
import FlatCategory from './FlatCategory';
/*
constants
*/
const PAGE_SIZE = 60;
export default function CustomizationsPage() {
const store = useInventoryStore();
const [allItems, setAllItems] = useState<CustomizationItem[]>([]);
const [characters, setCharacters] = useState<Character[]>([]);
const [tab, setTab] = useState<Tab>('cosmetics');
const [selectedChar, setSelectedChar] = useState<number | null>(null);
const [charSearch, setCharSearch] = useState('');
const [charRole, setCharRole] = useState<RoleFilter>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
useEffect(() => {
Promise.all([
fetch('/data/customization_items.json').then(r => r.json()).catch(() => []),
fetch('/data/characters.json').then(r => r.json()).catch(() => []),
]).then(([items, chars]) => {
setAllItems(items);
setCharacters(chars);
});
}, []);
useEffect(() => { setPage(1); }, [tab, search]);
/*
derived data
*/
const characterMap = useMemo(() => {
const m = new Map<number, string>();
characters.forEach(c => m.set(c.idx, c.name));
return m;
}, [characters]);
const unlockedSet = useMemo(
() => new Set(store.unlockedCustomizations),
[store.unlockedCustomizations]
);
const itemsByCharacter = useMemo(() => {
const map = new Map<number, CustomizationItem[]>();
allItems.forEach(item => {
if (item.category >= 1 && item.category <= 7 && item.associatedCharacter > -1) {
if (!map.has(item.associatedCharacter)) map.set(item.associatedCharacter, []);
map.get(item.associatedCharacter)!.push(item);
}
});
return map;
}, [allItems]);
const charFullyUnlocked = useMemo(() => {
const result = new Map<number, boolean>();
characters.forEach(char => {
const items = itemsByCharacter.get(char.idx) ?? [];
result.set(char.idx, items.length > 0 && items.every(i => unlockedSet.has(i.id)));
});
return result;
}, [characters, itemsByCharacter, unlockedSet]);
const filteredChars = useMemo(() => {
return characters.filter(c => {
if (charRole === 'survivors' && isKiller(c.idx)) return false;
if (charRole === 'killers' && !isKiller(c.idx)) return false;
if (charSearch.trim() && !c.name.toLowerCase().includes(charSearch.toLowerCase())) return false;
return true;
});
}, [characters, charRole, charSearch]);
const flatItems = useMemo(() => {
if (tab === 'cosmetics') return [];
const cat = TAB_CATEGORIES[tab];
if (cat === undefined) return [];
return allItems.filter(item => {
if (item.category !== cat) return false;
if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [allItems, tab, search]);
const totalPages = Math.max(1, Math.ceil(flatItems.length / PAGE_SIZE));
const pagedItems = useMemo(
() => flatItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[flatItems, page]
);
const charCosmetics = useMemo(() => {
if (selectedChar === null) return {};
const items = itemsByCharacter.get(selectedChar) ?? [];
const groups: Record<number, CustomizationItem[]> = {};
CATEGORY_ORDER.forEach(cat => {
const group = items.filter(i => i.category === cat);
if (group.length > 0) groups[cat] = group;
});
return groups;
}, [itemsByCharacter, selectedChar]);
/*
lock/unlock helpers
*/
const mergeUnlock = (ids: string[]) => {
const merged = Array.from(new Set([...store.unlockedCustomizations, ...ids]));
store.unlockAllInCategory('customizations', merged);
};
const removeLock = (ids: string[]) => {
const toRemove = new Set(ids);
const remaining = store.unlockedCustomizations.filter(id => !toRemove.has(id));
store.unlockAllInCategory('customizations', remaining);
};
/*
handlers
*/
const handleToggle = (id: string) => store.toggleItem(id, 'customizations');
const handleUnlockShownCosmetics = () => {
const charSet = new Set(filteredChars.map(c => c.idx));
mergeUnlock(
allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id)
);
};
const handleLockShownCosmetics = () => {
const charSet = new Set(filteredChars.map(c => c.idx));
removeLock(
allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id)
);
};
const handleUnlockCharCosmetics = () => mergeUnlock(Object.values(charCosmetics).flat().map(i => i.id));
const handleLockCharCosmetics = () => removeLock(Object.values(charCosmetics).flat().map(i => i.id));
const handleUnlockAll = () => mergeUnlock(flatItems.map(i => i.id));
const handleLockAll = () => removeLock(flatItems.map(i => i.id));
const handleUnlockPage = () => mergeUnlock(pagedItems.map(i => i.id));
const handleLockPage = () => removeLock(pagedItems.map(i => i.id));
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>Customizations</h1>
<p className={shared.subtitle}>
{store.unlockedCustomizations.length} of {allItems.length || '-'} unlocked
</p>
</div>
<button className={shared.clearBtn} onClick={() => store.clearCategory('customizations')}>
Clear All
</button>
</header>
<div className={styles.tabs}>
{(['cosmetics', 'charms', 'badges', 'banners', 'portraits'] as Tab[]).map(t => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
onClick={() => { setTab(t); setSelectedChar(null); }}
>
{t === 'cosmetics' ? 'Character Cosmetics' : t}
</button>
))}
</div>
{tab === 'cosmetics' && selectedChar === null && (
<CharacterPicker
filteredChars={filteredChars}
charSearch={charSearch}
charRole={charRole}
charFullyUnlocked={charFullyUnlocked}
onSelect={setSelectedChar}
onSearchChange={setCharSearch}
onRoleChange={setCharRole}
onUnlockShownCosmetics={handleUnlockShownCosmetics}
onLockShownCosmetics={handleLockShownCosmetics}
/>
)}
{tab === 'cosmetics' && selectedChar !== null && (
<CharacterCosmetics
charName={characterMap.get(selectedChar) ?? selectedChar.toString()}
charCosmetics={charCosmetics}
unlockedSet={unlockedSet}
characterMap={characterMap}
onBack={() => setSelectedChar(null)}
onUnlockAll={handleUnlockCharCosmetics}
onLockAll={handleLockCharCosmetics}
onToggle={handleToggle}
/>
)}
{tab !== 'cosmetics' && (
<FlatCategory
tab={tab}
allFilteredItems={flatItems}
pagedItems={pagedItems}
page={page}
totalPages={totalPages}
search={search}
unlockedSet={unlockedSet}
characterMap={characterMap}
onSearchChange={setSearch}
onUnlockAll={handleUnlockAll}
onLockAll={handleLockAll}
onUnlockPage={handleUnlockPage}
onLockPage={handleLockPage}
onToggle={handleToggle}
onPageChange={setPage}
/>
)}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
export type CustomizationItem = {
id: string;
name: string;
iconFilePath: string;
category: number;
associatedCharacter: number;
};
export type Character = {
idx: number;
name: string;
iconFilePath: string;
};
export type Tab = 'cosmetics' | 'charms' | 'badges' | 'banners' | 'portraits';
export type RoleFilter = 'all' | 'survivors' | 'killers';
export const CATEGORY_LABELS: Record<number, string> = {
1: 'Heads', 2: 'Torsos', 3: 'Legs',
4: 'Heads', 5: 'Bodies', 6: 'Weapons', 7: 'Outfits',
};
export const CATEGORY_ORDER = [7, 1, 4, 2, 3, 5, 6];
export const TAB_CATEGORIES: Partial<Record<Tab, number>> = {
charms: 8, badges: 9, banners: 10, portraits: 11,
};
export const getCosmeticIconUrl = (
item: CustomizationItem,
characterMap: Map<number, string>
): string => {
const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0];
switch (item.category) {
case 8: return `/icons/customization/charms/${file}.png`;
case 9: return `/icons/customization/badges/${file}.png`;
case 10: return `/icons/customization/banners/${file}.png`;
case 11: return `/icons/customization/portrait-backgrounds/${file}.png`;
}
const subfolder =
item.category === 1 || item.category === 4 ? 'heads' :
item.category === 2 ? 'torsos' :
item.category === 3 ? 'legs' :
item.category === 5 ? 'bodys' :
item.category === 6 ? 'weapons' : 'outfits';
const charName = characterMap.get(item.associatedCharacter);
const charFolder = (charName ?? item.associatedCharacter.toString())
.replace(/[\\/:*?"<>|]/g, '_');
return `/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`;
};
+208
View File
@@ -0,0 +1,208 @@
/*
tabs
*/
.tabs {
display: flex;
gap: 0;
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;
}
/*
page btns
*/
.pageActionBtn {
background: transparent;
border: 1px solid #1e1e1e;
color: #3a3a3a;
padding: 0.6rem 1rem;
font-family: 'Oswald', sans-serif;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: all 0.15s ease;
}
.pageActionBtn:hover {
color: #666666;
border-color: #2e2e2e;
}
/*
char picker
*/
.charGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.75rem;
overflow-y: auto;
flex: 1;
align-content: start;
padding-right: 0.5rem;
}
.charCard {
background: #0d0d0d;
border: 1px solid #1a1a1a;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem 0.6rem;
gap: 0.4rem;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.charCard:hover {
border-color: #333333;
background: #111111;
}
.charCardSelected {
border-color: #a30000;
background: #0d0000;
}
.charCardIcon {
width: 56px;
height: 56px;
object-fit: contain;
}
.charCardName {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: center;
color: #555555;
line-height: 1.3;
}
.charCardSelected .charCardName {
color: #c9c9c9;
}
.charCardFullyUnlocked {
border-color: #6b4c00;
background: #0d0900;
}
.charCardFullyUnlocked:hover {
border-color: #b37a00;
box-shadow: 0 0 18px rgba(160, 110, 0, 0.3);
}
.charCardFullyUnlocked .charCardName {
color: #9a7020;
}
/*
sel view
*/
.cosmeticsView {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.cosmeticsHeader {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
flex-shrink: 0;
}
.backBtn {
background: transparent;
border: 1px solid #1a1a1a;
color: #555555;
padding: 0.5rem 1rem;
font-family: 'Oswald', sans-serif;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: all 0.15s ease;
}
.backBtn:hover {
color: #ffffff;
border-color: #333333;
}
.cosmeticsCharName {
font-family: 'Oswald', sans-serif;
font-size: 1.25rem;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cosmeticsBody {
overflow-y: auto;
flex: 1;
padding-right: 0.5rem;
}
.categoryGroup {
margin-bottom: 2.5rem;
}
.categoryTitle {
font-family: 'Oswald', sans-serif;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #444444;
border-bottom: 1px solid #1a1a1a;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
/*
grid
*/
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
overflow-y: auto;
flex: 1;
align-content: start;
padding-right: 0.5rem;
}
.gridInline {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
}