feat: add customizations page
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user