style: run format:apply

This commit is contained in:
2026-06-19 04:29:24 -03:00
parent f51a71a574
commit c2b94bec4a
36 changed files with 3251 additions and 2597 deletions
+118 -107
View File
@@ -10,133 +10,144 @@ import shared from '../../styles/shared.module.css';
import styles from '../../styles/Characters.module.css';
type Character = {
idx: number;
name: string;
iconFilePath: string;
idx: number;
name: string;
iconFilePath: string;
};
const getIconUrl = (iconFilePath: string) => {
const fileName = iconFilePath.split('/').pop()?.split('.')[0];
return `${DB_BASE_URL}/icons/character-icons/${fileName}.png`;
const fileName = iconFilePath.split('/').pop()?.split('.')[0];
return `${DB_BASE_URL}/icons/character-icons/${fileName}.png`;
};
type RoleFilter = 'all' | 'survivors' | 'killers';
export default function CharactersPage() {
const store = useInventoryStore();
const store = useInventoryStore();
const [characters, setCharacters] = useState<Character[]>([]);
const [search, setSearch] = useState('');
const [role, setRole] = useState<RoleFilter>('all');
const [characters, setCharacters] = useState<Character[]>([]);
const [search, setSearch] = useState('');
const [role, setRole] = useState<RoleFilter>('all');
useEffect(() => {
fetchCharacters().then(setCharacters);
}, []);
useEffect(() => {
fetchCharacters().then(setCharacters);
}, []);
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 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 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 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 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;
const handleClear = () => {
store.clearCategory('characters');
};
const unlockedCount = store.unlockedCharacters.length;
return (<div className={shared.container}>
<header className={shared.header}>
<div className={shared.headerLeft}>
<h1 className={shared.title}>Characters</h1>
<p className={shared.subtitle}>{unlockedCount} of {characters.length} unlocked</p>
</div>
</header>
return (
<div className={shared.container}>
<header className={shared.header}>
<div className={shared.headerLeft}>
<h1 className={shared.title}>Characters</h1>
<p className={shared.subtitle}>
{unlockedCount} of {characters.length} unlocked
</p>
</div>
</header>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search by name..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type='text'
placeholder='Search by name...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
{(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => (
<button
key={r}
className={`${shared.roleBtn} ${role === r ? shared.roleBtnActive : ''}`}
onClick={() => setRole(r)}
>
{r}
</button>
))}
</div>
<div className={shared.roleFilter}>
{(['all', 'survivors', 'killers'] as RoleFilter[]).map((r) => (
<button
key={r}
className={`${shared.roleBtn} ${role === r ? shared.roleBtnActive : ''}`}
onClick={() => setRole(r)}
>
{r}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown</span>
<span className={shared.resultCount}>{filtered.length} shown</span>
<button className={shared.unlockAllBtn} onClick={handleUnlockAll}>
Unlock shown
</button>
<button className={shared.unlockAllBtn} onClick={handleUnlockAll}>
Unlock shown
</button>
<button className={shared.lockAllBtn} onClick={handleLockAll}>
Lock shown
</button>
<button className={shared.clearBtn} onClick={handleClear}>
Clear all
</button>
</div>
<button className={shared.lockAllBtn} onClick={handleLockAll}>
Lock shown
</button>
<button className={shared.clearBtn} onClick={handleClear}>
Clear all
</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No characters match</div>
) : (
<div className={styles.grid}>
{filtered.map(char => {
const unlocked = store.unlockedCharacters.includes(char.idx.toString());
const killer = isKiller(char.idx);
return (
<div
key={char.idx}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => handleToggle(char.idx)}
>
<img
className={shared.cardIcon}
src={getIconUrl(char.iconFilePath)}
alt={char.name}
loading="lazy"
/>
<span className={shared.cardName}>{char.name}</span>
<span className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}>
{killer ? 'Killer' : 'Survivor'}
</span>
</div>
);
})}
</div>
)}
</div >)
}
{filtered.length === 0 ? (
<div className={shared.empty}>No characters match</div>
) : (
<div className={styles.grid}>
{filtered.map((char) => {
const unlocked = store.unlockedCharacters.includes(
char.idx.toString()
);
const killer = isKiller(char.idx);
return (
<div
key={char.idx}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => handleToggle(char.idx)}
>
<img
className={shared.cardIcon}
src={getIconUrl(char.iconFilePath)}
alt={char.name}
loading='lazy'
/>
<span className={shared.cardName}>{char.name}</span>
<span
className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}
>
{killer ? 'Killer' : 'Survivor'}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
+75 -60
View File
@@ -2,72 +2,87 @@
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Customizations.module.css';
import { CustomizationItem, CATEGORY_LABELS, CATEGORY_ORDER, getCosmeticIconUrl } from './types';
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;
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,
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;
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>
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>
);
<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>
);
}
+75 -72
View File
@@ -6,84 +6,87 @@ import { DB_BASE_URL } from '../../lib/db';
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;
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 `${DB_BASE_URL}/icons/character-icons/${file}.png`;
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/character-icons/${file}.png`;
};
export default function CharacterPicker({
filteredChars,
charSearch,
charRole,
charFullyUnlocked,
onSelect,
onSearchChange,
onRoleChange,
onUnlockShownCosmetics,
onLockShownCosmetics,
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>
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>
</>
);
<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>
</>
);
}
+133 -119
View File
@@ -5,132 +5,146 @@ 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;
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,
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;
};
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>
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>
{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>
{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>
))}
{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>
<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>
)}
</>
)}
</>
);
}
<span className={shared.pageInfo}>
{page} / {totalPages}
</span>
</div>
)}
</>
)}
</>
);
}
+243 -181
View File
@@ -8,7 +8,14 @@ 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 {
Character,
CustomizationItem,
RoleFilter,
Tab,
TAB_CATEGORIES,
CATEGORY_ORDER
} from './types';
import CharacterPicker from './CharacterPicker';
import CharacterCosmetics from './CharacterCosmetics';
import FlatCategory from './FlatCategory';
@@ -18,210 +25,265 @@ import FlatCategory from './FlatCategory';
*/
const PAGE_SIZE = 60;
export default function CustomizationsPage() {
const store = useInventoryStore();
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);
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(() => {
Promise.all([fetchCustomizations(), fetchCharacters()]).then(
([items, chars]) => {
setAllItems(items);
setCharacters(chars);
}
);
}, []);
useEffect(() => { setPage(1); }, [tab, search]);
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 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 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 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 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 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 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 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]);
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 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);
};
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 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 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 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 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 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));
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>
)
}
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>
);
}
+52 -32
View File
@@ -1,57 +1,77 @@
export type CustomizationItem = {
id: string;
name: string;
iconFilePath: string;
category: number;
associatedCharacter: number;
id: string;
name: string;
iconFilePath: string;
category: number;
associatedCharacter: number;
};
export type Character = {
idx: number;
name: string;
iconFilePath: string;
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',
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,
charms: 8,
badges: 9,
banners: 10,
portraits: 11
};
import { DB_BASE_URL } from '../../lib/db';
export const getCosmeticIconUrl = (
item: CustomizationItem,
characterMap: Map<number, string>
item: CustomizationItem,
characterMap: Map<number, string>
): string => {
const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0];
const base = DB_BASE_URL;
const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0];
const base = DB_BASE_URL;
switch (item.category) {
case 8: return `${base}/icons/customization/charms/${file}.png`;
case 9: return `${base}/icons/customization/badges/${file}.png`;
case 10: return `${base}/icons/customization/banners/${file}.png`;
case 11: return `${base}/icons/customization/portrait-backgrounds/${file}.png`;
}
switch (item.category) {
case 8:
return `${base}/icons/customization/charms/${file}.png`;
case 9:
return `${base}/icons/customization/badges/${file}.png`;
case 10:
return `${base}/icons/customization/banners/${file}.png`;
case 11:
return `${base}/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 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, '_');
const charName = characterMap.get(item.associatedCharacter);
const charFolder = (charName ?? item.associatedCharacter.toString()).replace(
/[\\/:*?"<>|]/g,
'_'
);
return `${base}/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`;
};
return `${base}/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`;
};
+155 -117
View File
@@ -4,139 +4,177 @@ import { useState, useEffect, useMemo } from 'react';
import { useInventoryStore } from '@/store/useInventoryStore';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Dlcs.module.css';
import { PlatformFilter, isOnSteam, isOnEpic, isOnXbox, matchesPlatform, } from './types';
import {
PlatformFilter,
isOnSteam,
isOnEpic,
isOnXbox,
matchesPlatform
} from './types';
import { DLC, isNamedDLC } from '@/lib/utils';
import { fetchDLCs } from '@/lib/db';
const PLATFORM_FILTER_LABELS: Record<PlatformFilter, string> = { all: 'All', steam: 'Steam', epic: 'Epic', xbox: 'Xbox' };
const PLATFORM_FILTER_LABELS: Record<PlatformFilter, string> = {
all: 'All',
steam: 'Steam',
epic: 'Epic',
xbox: 'Xbox'
};
export default function DlcsPage() {
const store = useInventoryStore();
const store = useInventoryStore();
const [allDlcs, setAllDlcs] = useState<DLC[]>([]);
const [search, setSearch] = useState('');
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all');
const [allDlcs, setAllDlcs] = useState<DLC[]>([]);
const [search, setSearch] = useState('');
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
const [statusFilter, setStatusFilter] = useState<
'all' | 'unlocked' | 'locked'
>('all');
useEffect(() => {
fetchDLCs()
.then((data: DLC[]) => setAllDlcs(data.filter(isNamedDLC)));
}, []);
useEffect(() => {
fetchDLCs().then((data: DLC[]) => setAllDlcs(data.filter(isNamedDLC)));
}, []);
const filtered = useMemo(() => {
return allDlcs.filter(dlc => {
if (!matchesPlatform(dlc, platformFilter)) return false;
if (search.trim() && !dlc.name!.toLowerCase().includes(search.toLowerCase())) return false;
if (statusFilter === 'unlocked' && !store.unlockedDLCs.includes(dlc.id)) return false;
if (statusFilter === 'locked' && store.unlockedDLCs.includes(dlc.id)) return false;
return true;
});
}, [allDlcs, search, platformFilter, statusFilter, store.unlockedDLCs]);
const filtered = useMemo(() => {
return allDlcs.filter((dlc) => {
if (!matchesPlatform(dlc, platformFilter)) return false;
if (
search.trim() &&
!dlc.name!.toLowerCase().includes(search.toLowerCase())
)
return false;
if (statusFilter === 'unlocked' && !store.unlockedDLCs.includes(dlc.id))
return false;
if (statusFilter === 'locked' && store.unlockedDLCs.includes(dlc.id))
return false;
return true;
});
}, [allDlcs, search, platformFilter, statusFilter, store.unlockedDLCs]);
const handleToggle = (id: string) => store.toggleItem(id, 'dlcs');
const handleToggle = (id: string) => store.toggleItem(id, 'dlcs');
const handleUnlockShown = () => {
const ids = filtered.map(d => d.id);
const merged = Array.from(new Set([...store.unlockedDLCs, ...ids]));
store.unlockAllInCategory('dlcs', merged);
};
const handleUnlockShown = () => {
const ids = filtered.map((d) => d.id);
const merged = Array.from(new Set([...store.unlockedDLCs, ...ids]));
store.unlockAllInCategory('dlcs', merged);
};
const handleLockShown = () => {
const ids = new Set(filtered.map(d => d.id));
const remaining = store.unlockedDLCs.filter(id => !ids.has(id));
store.unlockAllInCategory('dlcs', remaining);
};
const handleLockShown = () => {
const ids = new Set(filtered.map((d) => d.id));
const remaining = store.unlockedDLCs.filter((id) => !ids.has(id));
store.unlockAllInCategory('dlcs', remaining);
};
const handleUnlockAll = () => {
store.unlockAllInCategory('dlcs', allDlcs.map(d => d.id));
};
const handleUnlockAll = () => {
store.unlockAllInCategory(
'dlcs',
allDlcs.map((d) => d.id)
);
};
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>DLCs</h1>
<p className={shared.subtitle}>
{store.unlockedDLCs.length} of {allDlcs.length || '-'} dlcs unlocked
</p>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button className={shared.unlockAllBtn} onClick={handleUnlockAll}>
Unlock All ({allDlcs.length})
</button>
<button className={shared.clearBtn} onClick={() => store.clearCategory('dlcs')}>
Clear All
</button>
</div>
</header>
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>DLCs</h1>
<p className={shared.subtitle}>
{store.unlockedDLCs.length} of {allDlcs.length || '-'} dlcs unlocked
</p>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button className={shared.unlockAllBtn} onClick={handleUnlockAll}>
Unlock All ({allDlcs.length})
</button>
<button
className={shared.clearBtn}
onClick={() => store.clearCategory('dlcs')}
>
Clear All
</button>
</div>
</header>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search DLCs..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type='text'
placeholder='Search DLCs...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
{(Object.keys(PLATFORM_FILTER_LABELS) as PlatformFilter[]).map(p => (
<button
key={p}
className={`${shared.roleBtn} ${platformFilter === p ? shared.roleBtnActive : ''}`}
onClick={() => setPlatformFilter(p)}
>
{PLATFORM_FILTER_LABELS[p]}
</button>
))}
</div>
<div className={shared.roleFilter}>
{(Object.keys(PLATFORM_FILTER_LABELS) as PlatformFilter[]).map(
(p) => (
<button
key={p}
className={`${shared.roleBtn} ${platformFilter === p ? shared.roleBtnActive : ''}`}
onClick={() => setPlatformFilter(p)}
>
{PLATFORM_FILTER_LABELS[p]}
</button>
)
)}
</div>
<div className={shared.roleFilter}>
{(['all', 'unlocked', 'locked'] as const).map(s => (
<button
key={s}
className={`${shared.roleBtn} ${statusFilter === s ? shared.roleBtnActive : ''}`}
onClick={() => setStatusFilter(s)}
>
{s}
</button>
))}
</div>
<div className={shared.roleFilter}>
{(['all', 'unlocked', 'locked'] as const).map((s) => (
<button
key={s}
className={`${shared.roleBtn} ${statusFilter === s ? shared.roleBtnActive : ''}`}
onClick={() => setStatusFilter(s)}
>
{s}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown</span>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown</span>
<button className={shared.unlockAllBtn} onClick={handleUnlockShown}>
Unlock Shown
</button>
<button className={shared.lockAllBtn} onClick={handleLockShown}>
Lock Shown
</button>
</div>
<button className={shared.unlockAllBtn} onClick={handleUnlockShown}>
Unlock Shown
</button>
<button className={shared.lockAllBtn} onClick={handleLockShown}>
Lock Shown
</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No DLCs match</div>
) : (
<div className={styles.grid}>
{filtered.map(dlc => {
const unlocked = store.unlockedDLCs.includes(dlc.id);
return (
<div
key={dlc.id}
className={`${styles.card} ${unlocked ? styles.cardUnlocked : ''}`}
onClick={() => handleToggle(dlc.id)}
>
<span className={styles.cardName}>{dlc.name}</span>
<div className={styles.platforms}>
<span className={`${styles.badge} ${isOnSteam(dlc) ? styles.badgeSteam : styles.badgeOff}`}>ST</span>
<span className={`${styles.badge} ${isOnEpic(dlc) ? styles.badgeEpic : styles.badgeOff}`}>EP</span>
<span className={`${styles.badge} ${isOnXbox(dlc) ? styles.badgeXbox : styles.badgeOff}`}>XB</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
{filtered.length === 0 ? (
<div className={shared.empty}>No DLCs match</div>
) : (
<div className={styles.grid}>
{filtered.map((dlc) => {
const unlocked = store.unlockedDLCs.includes(dlc.id);
return (
<div
key={dlc.id}
className={`${styles.card} ${unlocked ? styles.cardUnlocked : ''}`}
onClick={() => handleToggle(dlc.id)}
>
<span className={styles.cardName}>{dlc.name}</span>
<div className={styles.platforms}>
<span
className={`${styles.badge} ${isOnSteam(dlc) ? styles.badgeSteam : styles.badgeOff}`}
>
ST
</span>
<span
className={`${styles.badge} ${isOnEpic(dlc) ? styles.badgeEpic : styles.badgeOff}`}
>
EP
</span>
<span
className={`${styles.badge} ${isOnXbox(dlc) ? styles.badgeXbox : styles.badgeOff}`}
>
XB
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+10 -10
View File
@@ -1,20 +1,20 @@
import { DLC } from "@/lib/utils";
import { DLC } from '@/lib/utils';
export type PlatformFilter = 'all' | 'steam' | 'epic' | 'xbox';
export const isOnSteam = (dlc: DLC) =>
dlc.dlcIds.steam !== '0' && dlc.dlcIds.steam !== '-1';
dlc.dlcIds.steam !== '0' && dlc.dlcIds.steam !== '-1';
export const isOnEpic = (dlc: DLC) =>
dlc.dlcIds.epic !== 'FFFFFFFFFFFFFFFF' && dlc.dlcIds.epic !== '0';
dlc.dlcIds.epic !== 'FFFFFFFFFFFFFFFF' && dlc.dlcIds.epic !== '0';
export const isOnXbox = (dlc: DLC) =>
dlc.dlcIds.grdk !== '9ZZZZZZZZZZZ' && dlc.dlcIds.grdk !== '0';
dlc.dlcIds.grdk !== '9ZZZZZZZZZZZ' && dlc.dlcIds.grdk !== '0';
export const matchesPlatform = (dlc: DLC, filter: PlatformFilter) => {
if (filter === 'all') return true;
if (filter === 'steam') return isOnSteam(dlc);
if (filter === 'epic') return isOnEpic(dlc);
if (filter === 'xbox') return isOnXbox(dlc);
return true;
};
if (filter === 'all') return true;
if (filter === 'steam') return isOnSteam(dlc);
if (filter === 'epic') return isOnEpic(dlc);
if (filter === 'xbox') return isOnXbox(dlc);
return true;
};
+23 -18
View File
@@ -1,39 +1,44 @@
@import url('https://fonts.googleapis.com/css2?family=Oswald:wght@400;700&family=Roboto+Condensed:wght@400;700&display=swap');
@import "tailwindcss";
@import 'tailwindcss';
* {
box-sizing: border-box;
box-sizing: border-box;
}
body {
background: #050505;
color: #c9c9c9;
font-family: 'Roboto Condensed', sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
background: #050505;
color: #c9c9c9;
font-family: 'Roboto Condensed', sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Oswald', sans-serif;
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Oswald', sans-serif;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #050505;
background: #050505;
}
::-webkit-scrollbar-thumb {
background: #330000;
border-radius: 3px;
border: 1px solid #1a1a1a;
background: #330000;
border-radius: 3px;
border: 1px solid #1a1a1a;
}
::-webkit-scrollbar-thumb:hover {
background: #a30000;
}
background: #a30000;
}
+99 -61
View File
@@ -7,75 +7,113 @@ import QuantityCard from '../../components/QuantityCard';
import { Addon, getAddonIconUrl, randInRange } from './types';
type Props = {
addons: Addon[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
addons: Addon[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
};
export default function AddonGrid({ addons, quantities, randMin, randMax, onSetQty }: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>('all');
export default function AddonGrid({
addons,
quantities,
randMin,
randMax,
onSetQty
}: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>(
'all'
);
const filtered = useMemo(() => {
return addons.filter(addon => {
if (roleFilter === 'killer' && addon.role !== 1) return false;
if (roleFilter === 'survivor' && addon.role !== 2) return false;
if (search.trim() && !addon.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [addons, search, roleFilter]);
const filtered = useMemo(() => {
return addons.filter((addon) => {
if (roleFilter === 'killer' && addon.role !== 1) return false;
if (roleFilter === 'survivor' && addon.role !== 2) return false;
if (
search.trim() &&
!addon.name.toLowerCase().includes(search.toLowerCase())
)
return false;
return true;
});
}, [addons, search, roleFilter]);
const handleGive100Visible = () => filtered.forEach(a => onSetQty(a.id, 100));
const handleRandVisible = () => filtered.forEach(a => onSetQty(a.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach(a => onSetQty(a.id, 0));
const handleGive100Visible = () =>
filtered.forEach((a) => onSetQty(a.id, 100));
const handleRandVisible = () =>
filtered.forEach((a) => onSetQty(a.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach((a) => onSetQty(a.id, 0));
const activeCount = Object.values(quantities).filter(q => q > 0).length;
const activeCount = Object.values(quantities).filter((q) => q > 0).length;
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search addons..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type='text'
placeholder='Search addons...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
<button className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('all')}>All</button>
<button className={`${shared.roleBtn} ${roleFilter === 'survivor' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('survivor')}>Survivor</button>
<button className={`${shared.roleBtn} ${roleFilter === 'killer' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('killer')}>Killer</button>
</div>
<div className={shared.roleFilter}>
<button
className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter('all')}
>
All
</button>
<button
className={`${shared.roleBtn} ${roleFilter === 'survivor' ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter('survivor')}
>
Survivor
</button>
<button
className={`${shared.roleBtn} ${roleFilter === 'killer' ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter('killer')}
>
Killer
</button>
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active out of {addons.length} total</span>
<span className={shared.spacer} />
<span className={shared.resultCount}>
{filtered.length} shown · {activeCount} active out of {addons.length}{' '}
total
</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button>
</div>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>
Set all to 100
</button>
<button className={styles.randBtn} onClick={handleRandVisible}>
Randomize
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Remove all visible
</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No addons match</div>
) : (
<div className={styles.grid}>
{filtered.map(addon => (
<QuantityCard
key={addon.id}
id={addon.id}
name={addon.name}
iconUrl={getAddonIconUrl(addon.iconFilePath)}
qty={quantities[addon.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
{filtered.length === 0 ? (
<div className={shared.empty}>No addons match</div>
) : (
<div className={styles.grid}>
{filtered.map((addon) => (
<QuantityCard
key={addon.id}
id={addon.id}
name={addon.name}
iconUrl={getAddonIconUrl(addon.iconFilePath)}
qty={quantities[addon.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
+95 -68
View File
@@ -4,84 +4,111 @@ import { useState, useMemo } from 'react';
import shared from '../../styles/shared.module.css';
import styles from '../../styles/Items.module.css';
import QuantityCard from '../../components/QuantityCard';
import { Item, ItemType, ITEM_TYPE_LABELS, getItemType, getItemIconUrl } from './types';
import {
Item,
ItemType,
ITEM_TYPE_LABELS,
getItemType,
getItemIconUrl
} from './types';
import { randInRange } from '@/lib/utils';
type Props = {
items: Item[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
items: Item[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
};
export default function ItemGrid({ items, quantities, randMin, randMax, onSetQty }: Props) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ItemType>('all');
export default function ItemGrid({
items,
quantities,
randMin,
randMax,
onSetQty
}: Props) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ItemType>('all');
const filtered = useMemo(() => {
return items.filter(item => {
if (typeFilter !== 'all' && getItemType(item.id) !== typeFilter) return false;
if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [items, typeFilter, search]);
const filtered = useMemo(() => {
return items.filter((item) => {
if (typeFilter !== 'all' && getItemType(item.id) !== typeFilter)
return false;
if (
search.trim() &&
!item.name.toLowerCase().includes(search.toLowerCase())
)
return false;
return true;
});
}, [items, typeFilter, search]);
const handleGive100Visible = () => filtered.forEach(i => onSetQty(i.id, 100));
const handleRandVisible = () => filtered.forEach(i => onSetQty(i.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach(i => onSetQty(i.id, 0));
const handleGive100Visible = () =>
filtered.forEach((i) => onSetQty(i.id, 100));
const handleRandVisible = () =>
filtered.forEach((i) => onSetQty(i.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach((i) => onSetQty(i.id, 0));
const activeCount = Object.values(quantities).filter(q => q > 0).length;
const activeCount = Object.values(quantities).filter((q) => q > 0).length;
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search items..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type='text'
placeholder='Search items...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
{(Object.keys(ITEM_TYPE_LABELS) as ItemType[]).map(t => (
<button
key={t}
className={`${shared.roleBtn} ${typeFilter === t ? shared.roleBtnActive : ''}`}
onClick={() => setTypeFilter(t)}
>
{ITEM_TYPE_LABELS[t]}
</button>
))}
</div>
<div className={shared.roleFilter}>
{(Object.keys(ITEM_TYPE_LABELS) as ItemType[]).map((t) => (
<button
key={t}
className={`${shared.roleBtn} ${typeFilter === t ? shared.roleBtnActive : ''}`}
onClick={() => setTypeFilter(t)}
>
{ITEM_TYPE_LABELS[t]}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active</span>
<span className={shared.spacer} />
<span className={shared.resultCount}>
{filtered.length} shown · {activeCount} active
</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button>
</div>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>
Set all to 100
</button>
<button className={styles.randBtn} onClick={handleRandVisible}>
Randomize
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Remove all visible
</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No items match</div>
) : (
<div className={styles.grid}>
{filtered.map(item => (
<QuantityCard
key={item.id}
id={item.id}
name={item.name}
iconUrl={getItemIconUrl(item.iconFilePath)}
qty={quantities[item.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
{filtered.length === 0 ? (
<div className={shared.empty}>No items match</div>
) : (
<div className={styles.grid}>
{filtered.map((item) => (
<QuantityCard
key={item.id}
id={item.id}
name={item.name}
iconUrl={getItemIconUrl(item.iconFilePath)}
qty={quantities[item.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
+93 -73
View File
@@ -8,92 +8,112 @@ import { Offering, OfferingRole, getOfferingIconUrl } from './types';
import { randInRange } from '@/lib/utils';
type Props = {
offerings: Offering[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
offerings: Offering[];
quantities: Record<string, number>;
randMin: number;
randMax: number;
onSetQty: (id: string, qty: number) => void;
};
const ROLE_LABELS: Record<OfferingRole, string> = {
all: 'All', shared: 'Shared', killer: 'Killer', survivor: 'Survivor',
all: 'All',
shared: 'Shared',
killer: 'Killer',
survivor: 'Survivor'
};
const roleMatches = (offeringRole: number, filter: OfferingRole) => {
if (filter === 'all') return true;
if (filter === 'shared') return offeringRole === 0;
if (filter === 'killer') return offeringRole === 1;
if (filter === 'survivor') return offeringRole === 2;
return true;
if (filter === 'all') return true;
if (filter === 'shared') return offeringRole === 0;
if (filter === 'killer') return offeringRole === 1;
if (filter === 'survivor') return offeringRole === 2;
return true;
};
export default function OfferingGrid({ offerings, quantities, randMin, randMax, onSetQty }: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<OfferingRole>('all');
export default function OfferingGrid({
offerings,
quantities,
randMin,
randMax,
onSetQty
}: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<OfferingRole>('all');
const filtered = useMemo(() => {
return offerings.filter(o => {
if (!roleMatches(o.role, roleFilter)) return false;
if (search.trim() && !o.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [offerings, roleFilter, search]);
const filtered = useMemo(() => {
return offerings.filter((o) => {
if (!roleMatches(o.role, roleFilter)) return false;
if (search.trim() && !o.name.toLowerCase().includes(search.toLowerCase()))
return false;
return true;
});
}, [offerings, roleFilter, search]);
const handleGive100Visible = () => filtered.forEach(o => onSetQty(o.id, 100));
const handleRandVisible = () => filtered.forEach(o => onSetQty(o.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach(o => onSetQty(o.id, 0));
const handleGive100Visible = () =>
filtered.forEach((o) => onSetQty(o.id, 100));
const handleRandVisible = () =>
filtered.forEach((o) => onSetQty(o.id, randInRange(randMin, randMax)));
const handleLockVisible = () => filtered.forEach((o) => onSetQty(o.id, 0));
const activeCount = Object.values(quantities).filter(q => q > 0).length;
const activeCount = Object.values(quantities).filter((q) => q > 0).length;
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type="text"
placeholder="Search offerings..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
return (
<>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type='text'
placeholder='Search offerings...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}>
{(Object.keys(ROLE_LABELS) as OfferingRole[]).map(r => (
<button
key={r}
className={`${shared.roleBtn} ${roleFilter === r ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter(r)}
>
{ROLE_LABELS[r]}
</button>
))}
</div>
<div className={shared.roleFilter}>
{(Object.keys(ROLE_LABELS) as OfferingRole[]).map((r) => (
<button
key={r}
className={`${shared.roleBtn} ${roleFilter === r ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter(r)}
>
{ROLE_LABELS[r]}
</button>
))}
</div>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active</span>
<span className={shared.spacer} />
<span className={shared.resultCount}>
{filtered.length} shown · {activeCount} active
</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button>
</div>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>
Set all to 100
</button>
<button className={styles.randBtn} onClick={handleRandVisible}>
Randomize
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Remove all visible
</button>
</div>
{filtered.length === 0 ? (
<div className={shared.empty}>No offerings match</div>
) : (
<div className={styles.grid}>
{filtered.map(offering => (
<QuantityCard
key={offering.id}
id={offering.id}
name={offering.name}
iconUrl={getOfferingIconUrl(offering.iconFilePath)}
qty={quantities[offering.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
{filtered.length === 0 ? (
<div className={shared.empty}>No offerings match</div>
) : (
<div className={styles.grid}>
{filtered.map((offering) => (
<QuantityCard
key={offering.id}
id={offering.id}
name={offering.name}
iconUrl={getOfferingIconUrl(offering.iconFilePath)}
qty={quantities[offering.id] ?? 0}
randMin={randMin}
randMax={randMax}
onSetQty={onSetQty}
/>
))}
</div>
)}
</>
);
}
+105 -95
View File
@@ -15,110 +15,120 @@ import AddonGrid from './AddonGrid';
type Tab = 'items' | 'offerings' | 'addons';
export default function ItemsPage() {
const store = useInventoryStore();
const store = useInventoryStore();
const [tab, setTab] = useState<Tab>('items');
const [items, setItems] = useState<Item[]>([]);
const [offerings, setOfferings] = useState<Offering[]>([]);
const [addons, setAddons] = useState<Addon[]>([]);
const [tab, setTab] = useState<Tab>('items');
const [items, setItems] = useState<Item[]>([]);
const [offerings, setOfferings] = useState<Offering[]>([]);
const [addons, setAddons] = useState<Addon[]>([]);
const [randMin, setRandMin] = useState(50);
const [randMax, setRandMax] = useState(200);
const [randMin, setRandMin] = useState(50);
const [randMax, setRandMax] = useState(200);
useEffect(() => {
Promise.all([fetchItems(), fetchOfferings(), fetchAddons()])
.then(([i, o, a]) => {
setItems(i);
setOfferings(o);
setAddons(a);
});
}, []);
useEffect(() => {
Promise.all([fetchItems(), fetchOfferings(), fetchAddons()]).then(
([i, o, a]) => {
setItems(i);
setOfferings(o);
setAddons(a);
}
);
}, []);
const handleClearAll = () => {
store.clearCategory('items');
store.clearCategory('offerings');
store.clearCategory('addons');
};
const handleClearAll = () => {
store.clearCategory('items');
store.clearCategory('offerings');
store.clearCategory('addons');
};
const activeItems = Object.values(store.items).filter(q => q > 0).length;
const activeOfferings = Object.values(store.offerings).filter(q => q > 0).length;
const activeAddons = Object.values(store.addons).filter(q => q > 0).length;
const activeItems = Object.values(store.items).filter((q) => q > 0).length;
const activeOfferings = Object.values(store.offerings).filter(
(q) => q > 0
).length;
const activeAddons = Object.values(store.addons).filter((q) => q > 0).length;
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>Items & Offerings</h1>
<p className={shared.subtitle}>
{activeItems} items · {activeOfferings} offerings · {activeAddons} addons
</p>
</div>
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>Items & Offerings</h1>
<p className={shared.subtitle}>
{activeItems} items · {activeOfferings} offerings · {activeAddons}{' '}
addons
</p>
</div>
<div className={styles.rangeGroup}>
<span className={styles.rangeLabel}>Rand range</span>
<input
className={styles.rangeInput}
type="number"
value={randMin}
min={0}
max={5000}
onChange={e => setRandMin(Math.max(0, parseInt(e.target.value) || 0))}
/>
<span className={styles.rangeSep}></span>
<input
className={styles.rangeInput}
type="number"
value={randMax}
min={0}
max={5000}
onChange={e => setRandMax(Math.min(5000, parseInt(e.target.value) || 0))}
/>
</div>
<div className={styles.rangeGroup}>
<span className={styles.rangeLabel}>Rand range</span>
<input
className={styles.rangeInput}
type='number'
value={randMin}
min={0}
max={5000}
onChange={(e) =>
setRandMin(Math.max(0, parseInt(e.target.value) || 0))
}
/>
<span className={styles.rangeSep}></span>
<input
className={styles.rangeInput}
type='number'
value={randMax}
min={0}
max={5000}
onChange={(e) =>
setRandMax(Math.min(5000, parseInt(e.target.value) || 0))
}
/>
</div>
<button className={shared.clearBtn} onClick={handleClearAll}>Clear All</button>
</header>
<button className={shared.clearBtn} onClick={handleClearAll}>
Clear All
</button>
</header>
<div className={styles.tabs}>
{(['items', 'offerings', 'addons'] as Tab[]).map(t => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
onClick={() => setTab(t)}
>
{t}
</button>
))}
</div>
<div className={styles.tabs}>
{(['items', 'offerings', 'addons'] as Tab[]).map((t) => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
onClick={() => setTab(t)}
>
{t}
</button>
))}
</div>
{tab === 'items' && (
<ItemGrid
items={items}
quantities={store.items}
randMin={randMin}
randMax={randMax}
onSetQty={store.setItemQuantity}
/>
)}
{tab === 'items' && (
<ItemGrid
items={items}
quantities={store.items}
randMin={randMin}
randMax={randMax}
onSetQty={store.setItemQuantity}
/>
)}
{tab === 'offerings' && (
<OfferingGrid
offerings={offerings}
quantities={store.offerings}
randMin={randMin}
randMax={randMax}
onSetQty={store.setOfferingQuantity}
/>
)}
{tab === 'offerings' && (
<OfferingGrid
offerings={offerings}
quantities={store.offerings}
randMin={randMin}
randMax={randMax}
onSetQty={store.setOfferingQuantity}
/>
)}
{tab === 'addons' && (
<AddonGrid
addons={addons}
quantities={store.addons}
randMin={randMin}
randMax={randMax}
onSetQty={store.setAddonQuantity}
/>
)}
</div>
);
}
{tab === 'addons' && (
<AddonGrid
addons={addons}
quantities={store.addons}
randMin={randMin}
randMax={randMax}
onSetQty={store.setAddonQuantity}
/>
)}
</div>
);
}
+41 -29
View File
@@ -1,59 +1,71 @@
import { DB_BASE_URL } from '../../lib/db';
export type Item = {
id: string;
name: string;
iconFilePath: string;
id: string;
name: string;
iconFilePath: string;
};
export type Offering = {
id: string;
name: string;
iconFilePath: string;
role: number;
id: string;
name: string;
iconFilePath: string;
role: number;
};
export type Addon = {
id: string;
name: string;
iconFilePath: string;
role: number;
id: string;
name: string;
iconFilePath: string;
role: number;
};
export type ItemType = 'all' | 'toolbox' | 'flashlight' | 'medkit' | 'key' | 'map' | 'other';
export type ItemType =
| 'all'
| 'toolbox'
| 'flashlight'
| 'medkit'
| 'key'
| 'map'
| 'other';
export type OfferingRole = 'all' | 'shared' | 'killer' | 'survivor';
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
all: 'All', toolbox: 'Toolbox', flashlight: 'Flashlight',
medkit: 'Med-Kit', key: 'Key', map: 'Map', other: 'Other',
all: 'All',
toolbox: 'Toolbox',
flashlight: 'Flashlight',
medkit: 'Med-Kit',
key: 'Key',
map: 'Map',
other: 'Other'
};
export const getItemType = (id: string): ItemType => {
if (/toolbox/i.test(id)) return 'toolbox';
if (/flashlight/i.test(id)) return 'flashlight';
if (/medkit|med_?kit/i.test(id)) return 'medkit';
if (/key/i.test(id)) return 'key';
if (/map/i.test(id)) return 'map';
return 'other';
if (/toolbox/i.test(id)) return 'toolbox';
if (/flashlight/i.test(id)) return 'flashlight';
if (/medkit|med_?kit/i.test(id)) return 'medkit';
if (/key/i.test(id)) return 'key';
if (/map/i.test(id)) return 'map';
return 'other';
};
export const getItemIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/item-icons/${file}.png`;
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/item-icons/${file}.png`;
};
export const getOfferingIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/offering-icons/${file}.png`;
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/offering-icons/${file}.png`;
};
export const getAddonIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/addon-icons/${file}.png`;
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/addon-icons/${file}.png`;
};
export const randInRange = (min: number, max: number) => {
const lo = Math.min(min, max);
const hi = Math.max(min, max);
return Math.floor(Math.random() * (hi - lo + 1)) + lo;
const lo = Math.min(min, max);
const hi = Math.max(min, max);
return Math.floor(Math.random() * (hi - lo + 1)) + lo;
};
+22 -24
View File
@@ -1,31 +1,29 @@
import type { Metadata } from "next";
import Sidebar from "../components/Sidebar";
import styles from "../styles/Layout.module.css";
import "./globals.css";
import AppContainer from "@/components/Container";
import type { Metadata } from 'next';
import Sidebar from '../components/Sidebar';
import styles from '../styles/Layout.module.css';
import './globals.css';
import AppContainer from '@/components/Container';
export const metadata: Metadata = {
title: "Hex: Unlocked",
description: "",
title: 'Hex: Unlocked',
description: ''
};
export default function RootLayout({
children,
children
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<AppContainer>
<div className={styles.layoutContainer}>
<Sidebar />
<main className={styles.mainContent}>
{children}
</main>
</div>
</AppContainer>
</body>
</html>
);
}
return (
<html lang='en'>
<body>
<AppContainer>
<div className={styles.layoutContainer}>
<Sidebar />
<main className={styles.mainContent}>{children}</main>
</div>
</AppContainer>
</body>
</html>
);
}
+258 -173
View File
@@ -4,202 +4,287 @@ import { useState, useEffect, useMemo } from 'react';
import { useInventoryStore } from '@/store/useInventoryStore';
import styles from '../styles/Home.module.css';
import { isNamedDLC } from '@/lib/utils';
import { fetchCharacters, fetchItems, fetchOfferings, fetchCustomizations, fetchDLCs, fetchAddons, fetchPerks } from '@/lib/db';
import {
fetchCharacters,
fetchItems,
fetchOfferings,
fetchCustomizations,
fetchDLCs,
fetchAddons,
fetchPerks
} from '@/lib/db';
export default function Home() {
const store = useInventoryStore();
const store = useInventoryStore();
const [charCount, setCharCount] = useState(0);
const [custCount, setCustCount] = useState(0);
const [itemsCount, setItemsCount] = useState(0);
const [offeringsCount, setOfferingsCount] = useState(0);
const [dlcsCount, setDlcsCount] = useState(0);
const [addonsCount, setAddonsCount] = useState(0);
const [perksCount, setPerksCount] = useState(0);
const [charCount, setCharCount] = useState(0);
const [custCount, setCustCount] = useState(0);
const [itemsCount, setItemsCount] = useState(0);
const [offeringsCount, setOfferingsCount] = useState(0);
const [dlcsCount, setDlcsCount] = useState(0);
const [addonsCount, setAddonsCount] = useState(0);
const [perksCount, setPerksCount] = useState(0);
const [importText, setImportText] = useState('');
const [importText, setImportText] = useState('');
useEffect(() => {
Promise.all([
fetchCharacters(),
fetchItems(),
fetchOfferings(),
fetchCustomizations(),
fetchDLCs(),
fetchAddons(),
fetchPerks()
]).then(([chars, items, offerings, customizations, dlcs, addons, perks]) => {
setCharCount(chars.length);
setItemsCount(items.length);
setOfferingsCount(offerings.length);
setCustCount(customizations.length);
setDlcsCount(dlcs.filter(isNamedDLC).length);
setAddonsCount(addons.length);
setPerksCount(perks.length);
});
}, []);
useEffect(() => {
Promise.all([
fetchCharacters(),
fetchItems(),
fetchOfferings(),
fetchCustomizations(),
fetchDLCs(),
fetchAddons(),
fetchPerks()
]).then(
([chars, items, offerings, customizations, dlcs, addons, perks]) => {
setCharCount(chars.length);
setItemsCount(items.length);
setOfferingsCount(offerings.length);
setCustCount(customizations.length);
setDlcsCount(dlcs.filter(isNamedDLC).length);
setAddonsCount(addons.length);
setPerksCount(perks.length);
}
);
}, []);
/*
/*
profile handling
*/
const handleDownload = () => {
const blob = new Blob([importText], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'profile.json';
link.click();
URL.revokeObjectURL(url);
};
const handleDownload = () => {
const blob = new Blob([importText], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'profile.json';
link.click();
URL.revokeObjectURL(url);
};
const handleCopy = () => {
navigator.clipboard.writeText(importText);
};
const handleCopy = () => {
navigator.clipboard.writeText(importText);
};
const getExportText = () => {
return JSON.stringify({
unlockedCharacters: store.unlockedCharacters,
unlockedCustomizations: store.unlockedCustomizations,
unlockedDLCs: store.unlockedDLCs,
unlockedPerks: store.unlockedPerks,
items: store.items,
offerings: store.offerings,
addons: store.addons
}, null, 2);
};
const getExportText = () => {
return JSON.stringify(
{
unlockedCharacters: store.unlockedCharacters,
unlockedCustomizations: store.unlockedCustomizations,
unlockedDLCs: store.unlockedDLCs,
unlockedPerks: store.unlockedPerks,
items: store.items,
offerings: store.offerings,
addons: store.addons
},
null,
2
);
};
const handleImport = async () => {
try {
const parsed = JSON.parse(importText);
store.importProfile(parsed);
} catch (e) {
console.error("Invalid JSON", e);
}
};
const handleImport = async () => {
try {
const parsed = JSON.parse(importText);
store.importProfile(parsed);
} catch (e) {
console.error('Invalid JSON', e);
}
};
useEffect(() => {
setImportText(getExportText());
}, [store.unlockedCharacters, store.unlockedCustomizations, store.unlockedDLCs, store.unlockedPerks, store.items, store.offerings, store.addons]);
useEffect(() => {
setImportText(getExportText());
}, [
store.unlockedCharacters,
store.unlockedCustomizations,
store.unlockedDLCs,
store.unlockedPerks,
store.items,
store.offerings,
store.addons
]);
/*
/*
stats
*/
const unlockedItems = useMemo(() => Object.values(store.items).filter(qty => qty > 0).length, [store.items]);
const unlockedOfferings = useMemo(() => Object.values(store.offerings).filter(qty => qty > 0).length, [store.offerings]);
const unlockedAddons = useMemo(() => Object.values(store.addons).filter(qty => qty > 0).length, [store.addons]);
const unlockedPerks = store.unlockedPerks.length;
const unlockedItems = useMemo(
() => Object.values(store.items).filter((qty) => qty > 0).length,
[store.items]
);
const unlockedOfferings = useMemo(
() => Object.values(store.offerings).filter((qty) => qty > 0).length,
[store.offerings]
);
const unlockedAddons = useMemo(
() => Object.values(store.addons).filter((qty) => qty > 0).length,
[store.addons]
);
const unlockedPerks = store.unlockedPerks.length;
return (<div className={styles.container}>
<header className={styles.header}>
<h1 className={styles.title}>Dashboard</h1>
<p className={styles.subtitle}>Status overview and profile management</p>
</header>
return (
<div className={styles.container}>
<header className={styles.header}>
<h1 className={styles.title}>Dashboard</h1>
<p className={styles.subtitle}>
Status overview and profile management
</p>
</header>
{/* stats cards */}
<section className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}>Characters</div>
<div className={styles.statValue}>{store.unlockedCharacters.length} <span className={styles.statTotal}>/ {charCount || '-'}</span></div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Customizations</div>
<div className={styles.statValue}>{store.unlockedCustomizations.length} <span className={styles.statTotal}>/ {custCount || '-'}</span></div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>DLCs</div>
<div className={styles.statValue}>{store.unlockedDLCs.length} <span className={styles.statTotal}>/ {dlcsCount || '-'}</span></div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Items</div>
<div className={styles.statValue}>{unlockedItems} <span className={styles.statTotal}>/ {itemsCount || '-'}</span></div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Offerings</div>
<div className={styles.statValue}>{unlockedOfferings} <span className={styles.statTotal}>/ {offeringsCount || '-'}</span></div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Addons</div>
<div className={styles.statValue}>{unlockedAddons} <span className={styles.statTotal}>/ {addonsCount || '-'}</span></div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Perks</div>
<div className={styles.statValue}>{unlockedPerks} <span className={styles.statTotal}>/ {perksCount || '-'}</span></div>
</div>
</section>
{/* stats cards */}
<section className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}>Characters</div>
<div className={styles.statValue}>
{store.unlockedCharacters.length}{' '}
<span className={styles.statTotal}>/ {charCount || '-'}</span>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Customizations</div>
<div className={styles.statValue}>
{store.unlockedCustomizations.length}{' '}
<span className={styles.statTotal}>/ {custCount || '-'}</span>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>DLCs</div>
<div className={styles.statValue}>
{store.unlockedDLCs.length}{' '}
<span className={styles.statTotal}>/ {dlcsCount || '-'}</span>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Items</div>
<div className={styles.statValue}>
{unlockedItems}{' '}
<span className={styles.statTotal}>/ {itemsCount || '-'}</span>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Offerings</div>
<div className={styles.statValue}>
{unlockedOfferings}{' '}
<span className={styles.statTotal}>/ {offeringsCount || '-'}</span>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Addons</div>
<div className={styles.statValue}>
{unlockedAddons}{' '}
<span className={styles.statTotal}>/ {addonsCount || '-'}</span>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Perks</div>
<div className={styles.statValue}>
{unlockedPerks}{' '}
<span className={styles.statTotal}>/ {perksCount || '-'}</span>
</div>
</div>
</section>
{/* toggles */}
<section className={styles.panelGrid}>
<div className={styles.card}>
<h3 className={styles.cardTitle}>Spoofer Toggles</h3>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Item Spoofing</span>
<span className={styles.toggleDesc}>Spoof inventory items, addons, and offerings</span>
</div>
<label className={styles.switch}>
<input type="checkbox" checked={store.spoofItems} onChange={(e) => store.setToggle('spoofItems', e.target.checked)} />
<span className={styles.slider}></span>
</label>
</div>
{/* toggles */}
<section className={styles.panelGrid}>
<div className={styles.card}>
<h3 className={styles.cardTitle}>Spoofer Toggles</h3>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Perk Spoofing</span>
<span className={styles.toggleDesc}>Unlock all perk slots and custom builds</span>
</div>
<label className={styles.switch}>
<input type="checkbox" checked={store.spoofPerks} onChange={(e) => store.setToggle('spoofPerks', e.target.checked)} />
<span className={styles.slider}></span>
</label>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Item Spoofing</span>
<span className={styles.toggleDesc}>
Spoof inventory items, addons, and offerings
</span>
</div>
<label className={styles.switch}>
<input
type='checkbox'
checked={store.spoofItems}
onChange={(e) =>
store.setToggle('spoofItems', e.target.checked)
}
/>
<span className={styles.slider}></span>
</label>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Catalog Spoofing</span>
<span className={styles.toggleDesc}>Unlock cosmetics, characters, and outfits</span>
</div>
<label className={styles.switch}>
<input type="checkbox" checked={store.spoofCatalog} onChange={(e) => store.setToggle('spoofCatalog', e.target.checked)} />
<span className={styles.slider}></span>
</label>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Perk Spoofing</span>
<span className={styles.toggleDesc}>
Unlock all perk slots and custom builds
</span>
</div>
<label className={styles.switch}>
<input
type='checkbox'
checked={store.spoofPerks}
onChange={(e) =>
store.setToggle('spoofPerks', e.target.checked)
}
/>
<span className={styles.slider}></span>
</label>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>DLC Spoofing</span>
<span className={styles.toggleDesc}>Bypass Steam, Epic, and Xbox DLC ownership</span>
</div>
<label className={styles.switch}>
<input type="checkbox" checked={store.spoofDLCs} onChange={(e) => store.setToggle('spoofDLCs', e.target.checked)} />
<span className={styles.slider}></span>
</label>
</div>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Catalog Spoofing</span>
<span className={styles.toggleDesc}>
Unlock cosmetics, characters, and outfits
</span>
</div>
<label className={styles.switch}>
<input
type='checkbox'
checked={store.spoofCatalog}
onChange={(e) =>
store.setToggle('spoofCatalog', e.target.checked)
}
/>
<span className={styles.slider}></span>
</label>
</div>
{/* profile */}
<div className={styles.card}>
<h3 className={styles.cardTitle}>Profile Import / Export</h3>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>DLC Spoofing</span>
<span className={styles.toggleDesc}>
Bypass Steam, Epic, and Xbox DLC ownership
</span>
</div>
<label className={styles.switch}>
<input
type='checkbox'
checked={store.spoofDLCs}
onChange={(e) => store.setToggle('spoofDLCs', e.target.checked)}
/>
<span className={styles.slider}></span>
</label>
</div>
</div>
<textarea
className={styles.textarea}
value={importText}
onChange={(e) => setImportText(e.target.value)}
/>
{/* profile */}
<div className={styles.card}>
<h3 className={styles.cardTitle}>Profile Import / Export</h3>
<div className={styles.btnRow}>
<button className={styles.primaryBtn} onClick={handleImport}>
Validate & Import
</button>
<button className={styles.secondaryBtn} onClick={handleCopy}>
Copy to Clipboard
</button>
<button className={styles.secondaryBtn} onClick={handleDownload}>
Download file
</button>
</div>
<textarea
className={styles.textarea}
value={importText}
onChange={(e) => setImportText(e.target.value)}
/>
</div>
</section>
</div>)
<div className={styles.btnRow}>
<button className={styles.primaryBtn} onClick={handleImport}>
Validate & Import
</button>
<button className={styles.secondaryBtn} onClick={handleCopy}>
Copy to Clipboard
</button>
<button className={styles.secondaryBtn} onClick={handleDownload}>
Download file
</button>
</div>
</div>
</section>
</div>
);
}
+112 -68
View File
@@ -8,79 +8,123 @@ import { fetchPerks } from '../../lib/db';
import { getPerkIconUrl, Perk } from './types';
export default function PerksPage() {
const store = useInventoryStore();
const [perks, setPerks] = useState<Perk[]>([]);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>('all');
const store = useInventoryStore();
const [perks, setPerks] = useState<Perk[]>([]);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>(
'all'
);
useEffect(() => {
fetchPerks().then(setPerks);
}, []);
useEffect(() => {
fetchPerks().then(setPerks);
}, []);
const filtered = useMemo(() => {
return perks.filter(p => {
if (roleFilter === 'killer' && p.role !== 1) return false;
if (roleFilter === 'survivor' && p.role !== 2) return false;
if (search.trim() && !p.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [perks, search, roleFilter]);
const filtered = useMemo(() => {
return perks.filter((p) => {
if (roleFilter === 'killer' && p.role !== 1) return false;
if (roleFilter === 'survivor' && p.role !== 2) return false;
if (search.trim() && !p.name.toLowerCase().includes(search.toLowerCase()))
return false;
return true;
});
}, [perks, search, roleFilter]);
const handleClearAll = () => store.clearCategory('perks');
const handleUnlockVisible = () => store.unlockAllInCategory('perks', Array.from(new Set([...store.unlockedPerks, ...filtered.map(p => p.id)])));
const handleLockVisible = () => store.unlockAllInCategory('perks', store.unlockedPerks.filter(id => !filtered.find(p => p.id === id)));
const handleClearAll = () => store.clearCategory('perks');
const handleUnlockVisible = () =>
store.unlockAllInCategory(
'perks',
Array.from(
new Set([...store.unlockedPerks, ...filtered.map((p) => p.id)])
)
);
const handleLockVisible = () =>
store.unlockAllInCategory(
'perks',
store.unlockedPerks.filter((id) => !filtered.find((p) => p.id === id))
);
const activeCount = store.unlockedPerks.length;
const activeCount = store.unlockedPerks.length;
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>Perks</h1>
<p className={shared.subtitle}>{activeCount} active out of {perks.length} total</p>
</div>
<button className={shared.clearBtn} onClick={handleClearAll}>Clear All</button>
</header>
return (
<div className={shared.container}>
<header className={shared.header}>
<div>
<h1 className={shared.title}>Perks</h1>
<p className={shared.subtitle}>
{activeCount} active out of {perks.length} total
</p>
</div>
<button className={shared.clearBtn} onClick={handleClearAll}>
Clear All
</button>
</header>
<div className={shared.toolbar}>
<input className={shared.searchInput} type="text" placeholder="Search perks..." value={search} onChange={e => setSearch(e.target.value)} />
<div className={shared.roleFilter}>
<button className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('all')}>All</button>
<button className={`${shared.roleBtn} ${roleFilter === 'survivor' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('survivor')}>Survivor</button>
<button className={`${shared.roleBtn} ${roleFilter === 'killer' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('killer')}>Killer</button>
</div>
<div className={shared.toolbar}>
<input
className={shared.searchInput}
type='text'
placeholder='Search perks...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown</span>
<button className={shared.unlockAllBtn} onClick={handleUnlockVisible}>Unlock visible</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Lock visible</button>
</div>
<div className={shared.roleFilter}>
<button
className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter('all')}
>
All
</button>
<button
className={`${shared.roleBtn} ${roleFilter === 'survivor' ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter('survivor')}
>
Survivor
</button>
<button
className={`${shared.roleBtn} ${roleFilter === 'killer' ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter('killer')}
>
Killer
</button>
</div>
<div className={styles.grid}>
{filtered.map(perk => {
const unlocked = store.unlockedPerks.includes(perk.id);
const killer = perk.role === 1;
return (
<div
key={perk.id}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => store.toggleItem(perk.id, 'perks')}
>
<img
className={shared.cardIcon}
src={getPerkIconUrl(perk.iconFilePath)}
alt={perk.name}
loading="lazy"
/>
<span className={shared.cardName}>{perk.name}</span>
<span className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}>
{killer ? 'Killer' : 'Survivor'}
</span>
</div>
);
})}
</div>
</div>
);
}
<span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown</span>
<button className={shared.unlockAllBtn} onClick={handleUnlockVisible}>
Unlock visible
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Lock visible
</button>
</div>
<div className={styles.grid}>
{filtered.map((perk) => {
const unlocked = store.unlockedPerks.includes(perk.id);
const killer = perk.role === 1;
return (
<div
key={perk.id}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => store.toggleItem(perk.id, 'perks')}
>
<img
className={shared.cardIcon}
src={getPerkIconUrl(perk.iconFilePath)}
alt={perk.name}
loading='lazy'
/>
<span className={shared.cardName}>{perk.name}</span>
<span
className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}
>
{killer ? 'Killer' : 'Survivor'}
</span>
</div>
);
})}
</div>
</div>
);
}
+7 -7
View File
@@ -1,13 +1,13 @@
import { DB_BASE_URL } from "@/lib/db";
import { DB_BASE_URL } from '@/lib/db';
export type Perk = {
id: string;
name: string;
iconFilePath: string;
role: number;
id: string;
name: string;
iconFilePath: string;
role: number;
};
export const getPerkIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/perk-icons/${file}.png`;
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/perk-icons/${file}.png`;
};