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