290 lines
7.6 KiB
TypeScript
290 lines
7.6 KiB
TypeScript
'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 { fetchCharacters, fetchCustomizations } from '../../lib/db';
|
|
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([fetchCustomizations(), fetchCharacters()]).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'] 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>
|
|
);
|
|
}
|