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
+117 -106
View File
@@ -10,133 +10,144 @@ import shared from '../../styles/shared.module.css';
import styles from '../../styles/Characters.module.css'; import styles from '../../styles/Characters.module.css';
type Character = { type Character = {
idx: number; idx: number;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
}; };
const getIconUrl = (iconFilePath: string) => { const getIconUrl = (iconFilePath: string) => {
const fileName = iconFilePath.split('/').pop()?.split('.')[0]; const fileName = iconFilePath.split('/').pop()?.split('.')[0];
return `${DB_BASE_URL}/icons/character-icons/${fileName}.png`; return `${DB_BASE_URL}/icons/character-icons/${fileName}.png`;
}; };
type RoleFilter = 'all' | 'survivors' | 'killers'; type RoleFilter = 'all' | 'survivors' | 'killers';
export default function CharactersPage() { export default function CharactersPage() {
const store = useInventoryStore(); const store = useInventoryStore();
const [characters, setCharacters] = useState<Character[]>([]); const [characters, setCharacters] = useState<Character[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [role, setRole] = useState<RoleFilter>('all'); const [role, setRole] = useState<RoleFilter>('all');
useEffect(() => { useEffect(() => {
fetchCharacters().then(setCharacters); fetchCharacters().then(setCharacters);
}, []); }, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return characters.filter(c => { return characters.filter((c) => {
if (role === 'survivors' && isKiller(c.idx)) return false; if (role === 'survivors' && isKiller(c.idx)) return false;
if (role === 'killers' && !isKiller(c.idx)) return false; if (role === 'killers' && !isKiller(c.idx)) return false;
if (search.trim() && !c.name.toLowerCase().includes(search.toLowerCase())) return false; if (search.trim() && !c.name.toLowerCase().includes(search.toLowerCase()))
return true; return false;
}); return true;
}, [characters, search, role]); });
}, [characters, search, role]);
const handleToggle = (idx: number) => { const handleToggle = (idx: number) => {
store.toggleItem(idx.toString(), 'characters'); store.toggleItem(idx.toString(), 'characters');
}; };
const handleUnlockAll = () => { const handleUnlockAll = () => {
const ids = filtered.map(c => c.idx.toString()); const ids = filtered.map((c) => c.idx.toString());
const outside = store.unlockedCharacters.filter( const outside = store.unlockedCharacters.filter(
id => !filtered.some(c => c.idx.toString() === id) (id) => !filtered.some((c) => c.idx.toString() === id)
); );
store.unlockAllInCategory('characters', [...outside, ...ids]); store.unlockAllInCategory('characters', [...outside, ...ids]);
}; };
const handleLockAll = () => { const handleLockAll = () => {
const ids = filtered.map(c => c.idx.toString()); const ids = filtered.map((c) => c.idx.toString());
const newUnlocked = store.unlockedCharacters.filter(id => !ids.includes(id)); const newUnlocked = store.unlockedCharacters.filter(
store.unlockAllInCategory('characters', newUnlocked); (id) => !ids.includes(id)
}; );
store.unlockAllInCategory('characters', newUnlocked);
};
const handleClear = () => { const handleClear = () => {
store.clearCategory('characters'); store.clearCategory('characters');
}; };
const unlockedCount = store.unlockedCharacters.length; const unlockedCount = store.unlockedCharacters.length;
return (<div className={shared.container}> return (
<header className={shared.header}> <div className={shared.container}>
<div className={shared.headerLeft}> <header className={shared.header}>
<h1 className={shared.title}>Characters</h1> <div className={shared.headerLeft}>
<p className={shared.subtitle}>{unlockedCount} of {characters.length} unlocked</p> <h1 className={shared.title}>Characters</h1>
</div> <p className={shared.subtitle}>
</header> {unlockedCount} of {characters.length} unlocked
</p>
</div>
</header>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder="Search by name..." placeholder='Search by name...'
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
{(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => ( {(['all', 'survivors', 'killers'] as RoleFilter[]).map((r) => (
<button <button
key={r} key={r}
className={`${shared.roleBtn} ${role === r ? shared.roleBtnActive : ''}`} className={`${shared.roleBtn} ${role === r ? shared.roleBtnActive : ''}`}
onClick={() => setRole(r)} onClick={() => setRole(r)}
> >
{r} {r}
</button> </button>
))} ))}
</div> </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}> <button className={shared.unlockAllBtn} onClick={handleUnlockAll}>
Unlock shown Unlock shown
</button> </button>
<button className={shared.lockAllBtn} onClick={handleLockAll}> <button className={shared.lockAllBtn} onClick={handleLockAll}>
Lock shown Lock shown
</button> </button>
<button className={shared.clearBtn} onClick={handleClear}> <button className={shared.clearBtn} onClick={handleClear}>
Clear all Clear all
</button> </button>
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={shared.empty}>No characters match</div> <div className={shared.empty}>No characters match</div>
) : ( ) : (
<div className={styles.grid}> <div className={styles.grid}>
{filtered.map(char => { {filtered.map((char) => {
const unlocked = store.unlockedCharacters.includes(char.idx.toString()); const unlocked = store.unlockedCharacters.includes(
const killer = isKiller(char.idx); char.idx.toString()
return ( );
<div const killer = isKiller(char.idx);
key={char.idx} return (
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`} <div
onClick={() => handleToggle(char.idx)} key={char.idx}
> className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
<img onClick={() => handleToggle(char.idx)}
className={shared.cardIcon} >
src={getIconUrl(char.iconFilePath)} <img
alt={char.name} className={shared.cardIcon}
loading="lazy" src={getIconUrl(char.iconFilePath)}
/> alt={char.name}
<span className={shared.cardName}>{char.name}</span> loading='lazy'
<span className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}> />
{killer ? 'Killer' : 'Survivor'} <span className={shared.cardName}>{char.name}</span>
</span> <span
</div> className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}
); >
})} {killer ? 'Killer' : 'Survivor'}
</div> </span>
)} </div>
</div >) );
})}
</div>
)}
</div>
);
} }
+75 -60
View File
@@ -2,72 +2,87 @@
import shared from '../../styles/shared.module.css'; import shared from '../../styles/shared.module.css';
import styles from '../../styles/Customizations.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 = { type Props = {
charName: string; charName: string;
charCosmetics: Record<number, CustomizationItem[]>; charCosmetics: Record<number, CustomizationItem[]>;
unlockedSet: Set<string>; unlockedSet: Set<string>;
characterMap: Map<number, string>; characterMap: Map<number, string>;
onBack: () => void; onBack: () => void;
onUnlockAll: () => void; onUnlockAll: () => void;
onLockAll: () => void; onLockAll: () => void;
onToggle: (id: string) => void; onToggle: (id: string) => void;
}; };
export default function CharacterCosmetics({ export default function CharacterCosmetics({
charName, charName,
charCosmetics, charCosmetics,
unlockedSet, unlockedSet,
characterMap, characterMap,
onBack, onBack,
onUnlockAll, onUnlockAll,
onLockAll, onLockAll,
onToggle, onToggle
}: Props) { }: Props) {
const allItems = Object.values(charCosmetics).flat(); const allItems = Object.values(charCosmetics).flat();
const unlockedCount = allItems.filter(i => unlockedSet.has(i.id)).length; const unlockedCount = allItems.filter((i) => unlockedSet.has(i.id)).length;
return ( return (
<div className={styles.cosmeticsView}> <div className={styles.cosmeticsView}>
<div className={styles.cosmeticsHeader}> <div className={styles.cosmeticsHeader}>
<button className={styles.backBtn} onClick={onBack}> Back</button> <button className={styles.backBtn} onClick={onBack}>
<span className={styles.cosmeticsCharName}>{charName}</span> Back
<span className={shared.resultCount}>{unlockedCount} / {allItems.length} unlocked</span> </button>
<span className={shared.spacer} /> <span className={styles.cosmeticsCharName}>{charName}</span>
<button className={shared.unlockAllBtn} onClick={onUnlockAll}>Unlock all</button> <span className={shared.resultCount}>
<button className={shared.lockAllBtn} onClick={onLockAll}>Lock all</button> {unlockedCount} / {allItems.length} unlocked
</div> </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}> <div className={styles.cosmeticsBody}>
{CATEGORY_ORDER.filter(cat => charCosmetics[cat]?.length > 0).map(cat => ( {CATEGORY_ORDER.filter((cat) => charCosmetics[cat]?.length > 0).map(
<div key={cat} className={styles.categoryGroup}> (cat) => (
<div className={styles.categoryTitle}> <div key={cat} className={styles.categoryGroup}>
{CATEGORY_LABELS[cat] ?? `Category ${cat}`} <div className={styles.categoryTitle}>
</div> {CATEGORY_LABELS[cat] ?? `Category ${cat}`}
<div className={styles.gridInline}> </div>
{charCosmetics[cat].map(item => { <div className={styles.gridInline}>
const unlocked = unlockedSet.has(item.id); {charCosmetics[cat].map((item) => {
return ( const unlocked = unlockedSet.has(item.id);
<div return (
key={item.id} <div
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`} key={item.id}
onClick={() => onToggle(item.id)} className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
> onClick={() => onToggle(item.id)}
<img >
className={shared.cardIcon} <img
src={getCosmeticIconUrl(item, characterMap)} className={shared.cardIcon}
alt={item.name} src={getCosmeticIconUrl(item, characterMap)}
loading="lazy" alt={item.name}
/> loading='lazy'
<span className={shared.cardName}>{item.name}</span> />
</div> <span className={shared.cardName}>{item.name}</span>
); </div>
})} );
</div> })}
</div> </div>
))} </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'; import { Character, RoleFilter } from './types';
type Props = { type Props = {
filteredChars: Character[]; filteredChars: Character[];
charSearch: string; charSearch: string;
charRole: RoleFilter; charRole: RoleFilter;
charFullyUnlocked: Map<number, boolean>; charFullyUnlocked: Map<number, boolean>;
onSelect: (idx: number) => void; onSelect: (idx: number) => void;
onSearchChange: (s: string) => void; onSearchChange: (s: string) => void;
onRoleChange: (r: RoleFilter) => void; onRoleChange: (r: RoleFilter) => void;
onUnlockShownCosmetics: () => void; onUnlockShownCosmetics: () => void;
onLockShownCosmetics: () => void; onLockShownCosmetics: () => void;
}; };
const getCharIconUrl = (iconFilePath: string) => { const getCharIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0]; const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/character-icons/${file}.png`; return `${DB_BASE_URL}/icons/character-icons/${file}.png`;
}; };
export default function CharacterPicker({ export default function CharacterPicker({
filteredChars, filteredChars,
charSearch, charSearch,
charRole, charRole,
charFullyUnlocked, charFullyUnlocked,
onSelect, onSelect,
onSearchChange, onSearchChange,
onRoleChange, onRoleChange,
onUnlockShownCosmetics, onUnlockShownCosmetics,
onLockShownCosmetics, onLockShownCosmetics
}: Props) { }: Props) {
return ( return (
<> <>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder="Search character..." placeholder='Search character...'
value={charSearch} value={charSearch}
onChange={e => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
/> />
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
{(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => ( {(['all', 'survivors', 'killers'] as RoleFilter[]).map((r) => (
<button <button
key={r} key={r}
className={`${shared.roleBtn} ${charRole === r ? shared.roleBtnActive : ''}`} className={`${shared.roleBtn} ${charRole === r ? shared.roleBtnActive : ''}`}
onClick={() => onRoleChange(r)} onClick={() => onRoleChange(r)}
> >
{r} {r}
</button> </button>
))} ))}
</div> </div>
<span className={shared.spacer} /> <span className={shared.spacer} />
<span className={shared.resultCount}>{filteredChars.length} shown</span> <span className={shared.resultCount}>{filteredChars.length} shown</span>
<button className={shared.unlockAllBtn} onClick={onUnlockShownCosmetics}> <button
Unlock Shown Cosmetics className={shared.unlockAllBtn}
</button> onClick={onUnlockShownCosmetics}
<button className={shared.lockAllBtn} onClick={onLockShownCosmetics}> >
Lock Shown Cosmetics Unlock Shown Cosmetics
</button> </button>
</div> <button className={shared.lockAllBtn} onClick={onLockShownCosmetics}>
Lock Shown Cosmetics
</button>
</div>
<div className={styles.charGrid}> <div className={styles.charGrid}>
{filteredChars.map(char => { {filteredChars.map((char) => {
const fullyUnlocked = charFullyUnlocked.get(char.idx) ?? false; const fullyUnlocked = charFullyUnlocked.get(char.idx) ?? false;
return ( return (
<div <div
key={char.idx} key={char.idx}
className={`${styles.charCard} ${fullyUnlocked ? styles.charCardFullyUnlocked : ''}`} className={`${styles.charCard} ${fullyUnlocked ? styles.charCardFullyUnlocked : ''}`}
onClick={() => onSelect(char.idx)} onClick={() => onSelect(char.idx)}
> >
<img <img
className={styles.charCardIcon} className={styles.charCardIcon}
src={getCharIconUrl(char.iconFilePath)} src={getCharIconUrl(char.iconFilePath)}
alt={char.name} alt={char.name}
loading="lazy" loading='lazy'
/> />
<span className={styles.charCardName}>{char.name}</span> <span className={styles.charCardName}>{char.name}</span>
</div> </div>
); );
})} })}
</div> </div>
</> </>
); );
} }
+132 -118
View File
@@ -5,132 +5,146 @@ import styles from '../../styles/Customizations.module.css';
import { CustomizationItem, Tab, getCosmeticIconUrl } from './types'; import { CustomizationItem, Tab, getCosmeticIconUrl } from './types';
type Props = { type Props = {
tab: Tab; tab: Tab;
allFilteredItems: CustomizationItem[]; allFilteredItems: CustomizationItem[];
pagedItems: CustomizationItem[]; pagedItems: CustomizationItem[];
page: number; page: number;
totalPages: number; totalPages: number;
search: string; search: string;
unlockedSet: Set<string>; unlockedSet: Set<string>;
characterMap: Map<number, string>; characterMap: Map<number, string>;
onSearchChange: (s: string) => void; onSearchChange: (s: string) => void;
onUnlockAll: () => void; onUnlockAll: () => void;
onLockAll: () => void; onLockAll: () => void;
onUnlockPage: () => void; onUnlockPage: () => void;
onLockPage: () => void; onLockPage: () => void;
onToggle: (id: string) => void; onToggle: (id: string) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
}; };
export default function FlatCategory({ export default function FlatCategory({
tab, tab,
allFilteredItems, allFilteredItems,
pagedItems, pagedItems,
page, page,
totalPages, totalPages,
search, search,
unlockedSet, unlockedSet,
characterMap, characterMap,
onSearchChange, onSearchChange,
onUnlockAll, onUnlockAll,
onLockAll, onLockAll,
onUnlockPage, onUnlockPage,
onLockPage, onLockPage,
onToggle, onToggle,
onPageChange, onPageChange
}: Props) { }: Props) {
const buildPageNumbers = () => { const buildPageNumbers = () => {
const pages: number[] = []; const pages: number[] = [];
const start = Math.max(1, Math.min(page - 2, totalPages - 4)); const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const end = Math.min(totalPages, start + 4); const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) pages.push(i); for (let i = start; i <= end; i++) pages.push(i);
return pages; return pages;
}; };
return ( return (
<> <>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder={`Search ${tab}...`} placeholder={`Search ${tab}...`}
value={search} value={search}
onChange={e => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
/> />
<span className={shared.spacer} /> <span className={shared.spacer} />
<span className={shared.resultCount}>{allFilteredItems.length} items</span> <span className={shared.resultCount}>
<button className={shared.unlockAllBtn} onClick={onUnlockAll}> {allFilteredItems.length} items
Unlock all ({allFilteredItems.length}) </span>
</button> <button className={shared.unlockAllBtn} onClick={onUnlockAll}>
<button className={shared.lockAllBtn} onClick={onLockAll}> Unlock all ({allFilteredItems.length})
Lock all </button>
</button> <button className={shared.lockAllBtn} onClick={onLockAll}>
<button className={styles.pageActionBtn} onClick={onUnlockPage}> Lock all
Unlock visible </button>
</button> <button className={styles.pageActionBtn} onClick={onUnlockPage}>
<button className={styles.pageActionBtn} onClick={onLockPage}> Unlock visible
Lock visible </button>
</button> <button className={styles.pageActionBtn} onClick={onLockPage}>
</div> Lock visible
</button>
</div>
{allFilteredItems.length === 0 ? ( {allFilteredItems.length === 0 ? (
<div className={shared.empty}>No items found</div> <div className={shared.empty}>No items found</div>
) : ( ) : (
<> <>
<div className={styles.grid}> <div className={styles.grid}>
{pagedItems.map(item => { {pagedItems.map((item) => {
const unlocked = unlockedSet.has(item.id); const unlocked = unlockedSet.has(item.id);
return ( return (
<div <div
key={item.id} key={item.id}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`} className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => onToggle(item.id)} onClick={() => onToggle(item.id)}
> >
<img <img
className={shared.cardIcon} className={shared.cardIcon}
src={getCosmeticIconUrl(item, characterMap)} src={getCosmeticIconUrl(item, characterMap)}
alt={item.name} alt={item.name}
loading="lazy" loading='lazy'
/> />
<span className={shared.cardName}>{item.name}</span> <span className={shared.cardName}>{item.name}</span>
</div> </div>
); );
})} })}
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<div className={shared.pagination}> <div className={shared.pagination}>
<button <button
className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`} className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(1)} onClick={() => onPageChange(1)}
>«</button> >
<button «
className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`} </button>
onClick={() => onPageChange(page - 1)} <button
></button> className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(page - 1)}
>
</button>
{buildPageNumbers().map(n => ( {buildPageNumbers().map((n) => (
<button <button
key={n} key={n}
className={`${shared.pageBtn} ${n === page ? shared.pageBtnActive : ''}`} className={`${shared.pageBtn} ${n === page ? shared.pageBtnActive : ''}`}
onClick={() => onPageChange(n)} onClick={() => onPageChange(n)}
>{n}</button> >
))} {n}
</button>
))}
<button <button
className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`} className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
></button> >
<button
className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`} </button>
onClick={() => onPageChange(totalPages)} <button
>»</button> className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`}
onClick={() => onPageChange(totalPages)}
>
»
</button>
<span className={shared.pageInfo}>{page} / {totalPages}</span> <span className={shared.pageInfo}>
</div> {page} / {totalPages}
)} </span>
</> </div>
)} )}
</> </>
); )}
</>
);
} }
+242 -180
View File
@@ -8,7 +8,14 @@ import styles from '../../styles/Customizations.module.css';
import { getFileName, cleanFolderName, isKiller } from '../../lib/utils'; import { getFileName, cleanFolderName, isKiller } from '../../lib/utils';
import { fetchCharacters, fetchCustomizations } from '../../lib/db'; 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 CharacterPicker from './CharacterPicker';
import CharacterCosmetics from './CharacterCosmetics'; import CharacterCosmetics from './CharacterCosmetics';
import FlatCategory from './FlatCategory'; import FlatCategory from './FlatCategory';
@@ -18,210 +25,265 @@ import FlatCategory from './FlatCategory';
*/ */
const PAGE_SIZE = 60; const PAGE_SIZE = 60;
export default function CustomizationsPage() { export default function CustomizationsPage() {
const store = useInventoryStore(); const store = useInventoryStore();
const [allItems, setAllItems] = useState<CustomizationItem[]>([]); const [allItems, setAllItems] = useState<CustomizationItem[]>([]);
const [characters, setCharacters] = useState<Character[]>([]); const [characters, setCharacters] = useState<Character[]>([]);
const [tab, setTab] = useState<Tab>('cosmetics'); const [tab, setTab] = useState<Tab>('cosmetics');
const [selectedChar, setSelectedChar] = useState<number | null>(null); const [selectedChar, setSelectedChar] = useState<number | null>(null);
const [charSearch, setCharSearch] = useState(''); const [charSearch, setCharSearch] = useState('');
const [charRole, setCharRole] = useState<RoleFilter>('all'); const [charRole, setCharRole] = useState<RoleFilter>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
useEffect(() => { useEffect(() => {
Promise.all([fetchCustomizations(), fetchCharacters()]) Promise.all([fetchCustomizations(), fetchCharacters()]).then(
.then(([items, chars]) => { ([items, chars]) => {
setAllItems(items); setAllItems(items);
setCharacters(chars); setCharacters(chars);
}); }
}, []); );
}, []);
useEffect(() => { setPage(1); }, [tab, search]); useEffect(() => {
setPage(1);
}, [tab, search]);
/* /*
derived data derived data
*/ */
const characterMap = useMemo(() => { const characterMap = useMemo(() => {
const m = new Map<number, string>(); const m = new Map<number, string>();
characters.forEach(c => m.set(c.idx, c.name)); characters.forEach((c) => m.set(c.idx, c.name));
return m; return m;
}, [characters]); }, [characters]);
const unlockedSet = useMemo( const unlockedSet = useMemo(
() => new Set(store.unlockedCustomizations), () => new Set(store.unlockedCustomizations),
[store.unlockedCustomizations] [store.unlockedCustomizations]
); );
const itemsByCharacter = useMemo(() => { const itemsByCharacter = useMemo(() => {
const map = new Map<number, CustomizationItem[]>(); const map = new Map<number, CustomizationItem[]>();
allItems.forEach(item => { allItems.forEach((item) => {
if (item.category >= 1 && item.category <= 7 && item.associatedCharacter > -1) { if (
if (!map.has(item.associatedCharacter)) map.set(item.associatedCharacter, []); item.category >= 1 &&
map.get(item.associatedCharacter)!.push(item); item.category <= 7 &&
} item.associatedCharacter > -1
}); ) {
return map; if (!map.has(item.associatedCharacter))
}, [allItems]); map.set(item.associatedCharacter, []);
map.get(item.associatedCharacter)!.push(item);
}
});
return map;
}, [allItems]);
const charFullyUnlocked = useMemo(() => { const charFullyUnlocked = useMemo(() => {
const result = new Map<number, boolean>(); const result = new Map<number, boolean>();
characters.forEach(char => { characters.forEach((char) => {
const items = itemsByCharacter.get(char.idx) ?? []; const items = itemsByCharacter.get(char.idx) ?? [];
result.set(char.idx, items.length > 0 && items.every(i => unlockedSet.has(i.id))); result.set(
}); char.idx,
return result; items.length > 0 && items.every((i) => unlockedSet.has(i.id))
}, [characters, itemsByCharacter, unlockedSet]); );
});
return result;
}, [characters, itemsByCharacter, unlockedSet]);
const filteredChars = useMemo(() => { const filteredChars = useMemo(() => {
return characters.filter(c => { return characters.filter((c) => {
if (charRole === 'survivors' && isKiller(c.idx)) return false; if (charRole === 'survivors' && isKiller(c.idx)) return false;
if (charRole === 'killers' && !isKiller(c.idx)) return false; if (charRole === 'killers' && !isKiller(c.idx)) return false;
if (charSearch.trim() && !c.name.toLowerCase().includes(charSearch.toLowerCase())) return false; if (
return true; charSearch.trim() &&
}); !c.name.toLowerCase().includes(charSearch.toLowerCase())
}, [characters, charRole, charSearch]); )
return false;
return true;
});
}, [characters, charRole, charSearch]);
const flatItems = useMemo(() => { const flatItems = useMemo(() => {
if (tab === 'cosmetics') return []; if (tab === 'cosmetics') return [];
const cat = TAB_CATEGORIES[tab]; const cat = TAB_CATEGORIES[tab];
if (cat === undefined) return []; if (cat === undefined) return [];
return allItems.filter(item => { return allItems.filter((item) => {
if (item.category !== cat) return false; if (item.category !== cat) return false;
if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false; if (
return true; search.trim() &&
}); !item.name.toLowerCase().includes(search.toLowerCase())
}, [allItems, tab, search]); )
return false;
return true;
});
}, [allItems, tab, search]);
const totalPages = Math.max(1, Math.ceil(flatItems.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(flatItems.length / PAGE_SIZE));
const pagedItems = useMemo( const pagedItems = useMemo(
() => flatItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => flatItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[flatItems, page] [flatItems, page]
); );
const charCosmetics = useMemo(() => { const charCosmetics = useMemo(() => {
if (selectedChar === null) return {}; if (selectedChar === null) return {};
const items = itemsByCharacter.get(selectedChar) ?? []; const items = itemsByCharacter.get(selectedChar) ?? [];
const groups: Record<number, CustomizationItem[]> = {}; const groups: Record<number, CustomizationItem[]> = {};
CATEGORY_ORDER.forEach(cat => { CATEGORY_ORDER.forEach((cat) => {
const group = items.filter(i => i.category === cat); const group = items.filter((i) => i.category === cat);
if (group.length > 0) groups[cat] = group; if (group.length > 0) groups[cat] = group;
}); });
return groups; return groups;
}, [itemsByCharacter, selectedChar]); }, [itemsByCharacter, selectedChar]);
/* /*
lock/unlock helpers lock/unlock helpers
*/ */
const mergeUnlock = (ids: string[]) => { const mergeUnlock = (ids: string[]) => {
const merged = Array.from(new Set([...store.unlockedCustomizations, ...ids])); const merged = Array.from(
store.unlockAllInCategory('customizations', merged); new Set([...store.unlockedCustomizations, ...ids])
}; );
store.unlockAllInCategory('customizations', merged);
};
const removeLock = (ids: string[]) => { const removeLock = (ids: string[]) => {
const toRemove = new Set(ids); const toRemove = new Set(ids);
const remaining = store.unlockedCustomizations.filter(id => !toRemove.has(id)); const remaining = store.unlockedCustomizations.filter(
store.unlockAllInCategory('customizations', remaining); (id) => !toRemove.has(id)
}; );
store.unlockAllInCategory('customizations', remaining);
};
/* /*
handlers handlers
*/ */
const handleToggle = (id: string) => store.toggleItem(id, 'customizations'); const handleToggle = (id: string) => store.toggleItem(id, 'customizations');
const handleUnlockShownCosmetics = () => { const handleUnlockShownCosmetics = () => {
const charSet = new Set(filteredChars.map(c => c.idx)); const charSet = new Set(filteredChars.map((c) => c.idx));
mergeUnlock( mergeUnlock(
allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id) allItems
); .filter(
}; (i) =>
charSet.has(i.associatedCharacter) &&
i.category >= 1 &&
i.category <= 7
)
.map((i) => i.id)
);
};
const handleLockShownCosmetics = () => { const handleLockShownCosmetics = () => {
const charSet = new Set(filteredChars.map(c => c.idx)); const charSet = new Set(filteredChars.map((c) => c.idx));
removeLock( removeLock(
allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id) 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 handleUnlockCharCosmetics = () =>
const handleLockCharCosmetics = () => removeLock(Object.values(charCosmetics).flat().map(i => i.id)); 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 handleUnlockAll = () => mergeUnlock(flatItems.map((i) => i.id));
const handleLockAll = () => removeLock(flatItems.map(i => i.id)); const handleLockAll = () => removeLock(flatItems.map((i) => i.id));
const handleUnlockPage = () => mergeUnlock(pagedItems.map(i => i.id)); const handleUnlockPage = () => mergeUnlock(pagedItems.map((i) => i.id));
const handleLockPage = () => removeLock(pagedItems.map(i => i.id)); const handleLockPage = () => removeLock(pagedItems.map((i) => i.id));
return ( return (
<div className={shared.container}> <div className={shared.container}>
<header className={shared.header}> <header className={shared.header}>
<div> <div>
<h1 className={shared.title}>Customizations</h1> <h1 className={shared.title}>Customizations</h1>
<p className={shared.subtitle}> <p className={shared.subtitle}>
{store.unlockedCustomizations.length} of {allItems.length || '-'} unlocked {store.unlockedCustomizations.length} of {allItems.length || '-'}{' '}
</p> unlocked
</div> </p>
<button className={shared.clearBtn} onClick={() => store.clearCategory('customizations')}> </div>
Clear All <button
</button> className={shared.clearBtn}
</header> onClick={() => store.clearCategory('customizations')}
<div className={styles.tabs}> >
{(['cosmetics', 'charms', 'badges', 'banners', 'portraits'] as Tab[]).map(t => ( Clear All
<button </button>
key={t} </header>
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`} <div className={styles.tabs}>
onClick={() => { setTab(t); setSelectedChar(null); }} {(
> ['cosmetics', 'charms', 'badges', 'banners', 'portraits'] as Tab[]
{t === 'cosmetics' ? 'Character Cosmetics' : t} ).map((t) => (
</button> <button
))} key={t}
</div> className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
{tab === 'cosmetics' && selectedChar === null && ( onClick={() => {
<CharacterPicker setTab(t);
filteredChars={filteredChars} setSelectedChar(null);
charSearch={charSearch} }}
charRole={charRole} >
charFullyUnlocked={charFullyUnlocked} {t === 'cosmetics' ? 'Character Cosmetics' : t}
onSelect={setSelectedChar} </button>
onSearchChange={setCharSearch} ))}
onRoleChange={setCharRole} </div>
onUnlockShownCosmetics={handleUnlockShownCosmetics} {tab === 'cosmetics' && selectedChar === null && (
onLockShownCosmetics={handleLockShownCosmetics} <CharacterPicker
/> filteredChars={filteredChars}
)} charSearch={charSearch}
{tab === 'cosmetics' && selectedChar !== null && ( charRole={charRole}
<CharacterCosmetics charFullyUnlocked={charFullyUnlocked}
charName={characterMap.get(selectedChar) ?? selectedChar.toString()} onSelect={setSelectedChar}
charCosmetics={charCosmetics} onSearchChange={setCharSearch}
unlockedSet={unlockedSet} onRoleChange={setCharRole}
characterMap={characterMap} onUnlockShownCosmetics={handleUnlockShownCosmetics}
onBack={() => setSelectedChar(null)} onLockShownCosmetics={handleLockShownCosmetics}
onUnlockAll={handleUnlockCharCosmetics} />
onLockAll={handleLockCharCosmetics} )}
onToggle={handleToggle} {tab === 'cosmetics' && selectedChar !== null && (
/> <CharacterCosmetics
)} charName={characterMap.get(selectedChar) ?? selectedChar.toString()}
{tab !== 'cosmetics' && ( charCosmetics={charCosmetics}
<FlatCategory unlockedSet={unlockedSet}
tab={tab} characterMap={characterMap}
allFilteredItems={flatItems} onBack={() => setSelectedChar(null)}
pagedItems={pagedItems} onUnlockAll={handleUnlockCharCosmetics}
page={page} onLockAll={handleLockCharCosmetics}
totalPages={totalPages} onToggle={handleToggle}
search={search} />
unlockedSet={unlockedSet} )}
characterMap={characterMap} {tab !== 'cosmetics' && (
onSearchChange={setSearch} <FlatCategory
onUnlockAll={handleUnlockAll} tab={tab}
onLockAll={handleLockAll} allFilteredItems={flatItems}
onUnlockPage={handleUnlockPage} pagedItems={pagedItems}
onLockPage={handleLockPage} page={page}
onToggle={handleToggle} totalPages={totalPages}
onPageChange={setPage} search={search}
/> unlockedSet={unlockedSet}
)} characterMap={characterMap}
</div> onSearchChange={setSearch}
) onUnlockAll={handleUnlockAll}
onLockAll={handleLockAll}
onUnlockPage={handleUnlockPage}
onLockPage={handleLockPage}
onToggle={handleToggle}
onPageChange={setPage}
/>
)}
</div>
);
} }
+51 -31
View File
@@ -1,57 +1,77 @@
export type CustomizationItem = { export type CustomizationItem = {
id: string; id: string;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
category: number; category: number;
associatedCharacter: number; associatedCharacter: number;
}; };
export type Character = { export type Character = {
idx: number; idx: number;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
}; };
export type Tab = 'cosmetics' | 'charms' | 'badges' | 'banners' | 'portraits'; export type Tab = 'cosmetics' | 'charms' | 'badges' | 'banners' | 'portraits';
export type RoleFilter = 'all' | 'survivors' | 'killers'; export type RoleFilter = 'all' | 'survivors' | 'killers';
export const CATEGORY_LABELS: Record<number, string> = { export const CATEGORY_LABELS: Record<number, string> = {
1: 'Heads', 2: 'Torsos', 3: 'Legs', 1: 'Heads',
4: 'Heads', 5: 'Bodies', 6: 'Weapons', 7: 'Outfits', 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 CATEGORY_ORDER = [7, 1, 4, 2, 3, 5, 6];
export const TAB_CATEGORIES: Partial<Record<Tab, number>> = { 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'; import { DB_BASE_URL } from '../../lib/db';
export const getCosmeticIconUrl = ( export const getCosmeticIconUrl = (
item: CustomizationItem, item: CustomizationItem,
characterMap: Map<number, string> characterMap: Map<number, string>
): string => { ): string => {
const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0]; const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0];
const base = DB_BASE_URL; const base = DB_BASE_URL;
switch (item.category) { switch (item.category) {
case 8: return `${base}/icons/customization/charms/${file}.png`; case 8:
case 9: return `${base}/icons/customization/badges/${file}.png`; return `${base}/icons/customization/charms/${file}.png`;
case 10: return `${base}/icons/customization/banners/${file}.png`; case 9:
case 11: return `${base}/icons/customization/portrait-backgrounds/${file}.png`; 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 = const subfolder =
item.category === 1 || item.category === 4 ? 'heads' : item.category === 1 || item.category === 4
item.category === 2 ? 'torsos' : ? 'heads'
item.category === 3 ? 'legs' : : item.category === 2
item.category === 5 ? 'bodys' : ? 'torsos'
item.category === 6 ? 'weapons' : 'outfits'; : item.category === 3
? 'legs'
: item.category === 5
? 'bodys'
: item.category === 6
? 'weapons'
: 'outfits';
const charName = characterMap.get(item.associatedCharacter); const charName = characterMap.get(item.associatedCharacter);
const charFolder = (charName ?? item.associatedCharacter.toString()) const charFolder = (charName ?? item.associatedCharacter.toString()).replace(
.replace(/[\\/:*?"<>|]/g, '_'); /[\\/:*?"<>|]/g,
'_'
);
return `${base}/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`; return `${base}/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`;
}; };
+154 -116
View File
@@ -4,139 +4,177 @@ import { useState, useEffect, useMemo } from 'react';
import { useInventoryStore } from '@/store/useInventoryStore'; import { useInventoryStore } from '@/store/useInventoryStore';
import shared from '../../styles/shared.module.css'; import shared from '../../styles/shared.module.css';
import styles from '../../styles/Dlcs.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 { DLC, isNamedDLC } from '@/lib/utils';
import { fetchDLCs } from '@/lib/db'; 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() { export default function DlcsPage() {
const store = useInventoryStore(); const store = useInventoryStore();
const [allDlcs, setAllDlcs] = useState<DLC[]>([]); const [allDlcs, setAllDlcs] = useState<DLC[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all'); const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all'); const [statusFilter, setStatusFilter] = useState<
'all' | 'unlocked' | 'locked'
>('all');
useEffect(() => { useEffect(() => {
fetchDLCs() fetchDLCs().then((data: DLC[]) => setAllDlcs(data.filter(isNamedDLC)));
.then((data: DLC[]) => setAllDlcs(data.filter(isNamedDLC))); }, []);
}, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return allDlcs.filter(dlc => { return allDlcs.filter((dlc) => {
if (!matchesPlatform(dlc, platformFilter)) return false; if (!matchesPlatform(dlc, platformFilter)) return false;
if (search.trim() && !dlc.name!.toLowerCase().includes(search.toLowerCase())) return false; if (
if (statusFilter === 'unlocked' && !store.unlockedDLCs.includes(dlc.id)) return false; search.trim() &&
if (statusFilter === 'locked' && store.unlockedDLCs.includes(dlc.id)) return false; !dlc.name!.toLowerCase().includes(search.toLowerCase())
return true; )
}); return false;
}, [allDlcs, search, platformFilter, statusFilter, store.unlockedDLCs]); 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 handleUnlockShown = () => {
const ids = filtered.map(d => d.id); const ids = filtered.map((d) => d.id);
const merged = Array.from(new Set([...store.unlockedDLCs, ...ids])); const merged = Array.from(new Set([...store.unlockedDLCs, ...ids]));
store.unlockAllInCategory('dlcs', merged); store.unlockAllInCategory('dlcs', merged);
}; };
const handleLockShown = () => { const handleLockShown = () => {
const ids = new Set(filtered.map(d => d.id)); const ids = new Set(filtered.map((d) => d.id));
const remaining = store.unlockedDLCs.filter(id => !ids.has(id)); const remaining = store.unlockedDLCs.filter((id) => !ids.has(id));
store.unlockAllInCategory('dlcs', remaining); store.unlockAllInCategory('dlcs', remaining);
}; };
const handleUnlockAll = () => { const handleUnlockAll = () => {
store.unlockAllInCategory('dlcs', allDlcs.map(d => d.id)); store.unlockAllInCategory(
}; 'dlcs',
allDlcs.map((d) => d.id)
);
};
return ( return (
<div className={shared.container}> <div className={shared.container}>
<header className={shared.header}> <header className={shared.header}>
<div> <div>
<h1 className={shared.title}>DLCs</h1> <h1 className={shared.title}>DLCs</h1>
<p className={shared.subtitle}> <p className={shared.subtitle}>
{store.unlockedDLCs.length} of {allDlcs.length || '-'} dlcs unlocked {store.unlockedDLCs.length} of {allDlcs.length || '-'} dlcs unlocked
</p> </p>
</div> </div>
<div style={{ display: 'flex', gap: '0.75rem' }}> <div style={{ display: 'flex', gap: '0.75rem' }}>
<button className={shared.unlockAllBtn} onClick={handleUnlockAll}> <button className={shared.unlockAllBtn} onClick={handleUnlockAll}>
Unlock All ({allDlcs.length}) Unlock All ({allDlcs.length})
</button> </button>
<button className={shared.clearBtn} onClick={() => store.clearCategory('dlcs')}> <button
Clear All className={shared.clearBtn}
</button> onClick={() => store.clearCategory('dlcs')}
</div> >
</header> Clear All
</button>
</div>
</header>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder="Search DLCs..." placeholder='Search DLCs...'
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
{(Object.keys(PLATFORM_FILTER_LABELS) as PlatformFilter[]).map(p => ( {(Object.keys(PLATFORM_FILTER_LABELS) as PlatformFilter[]).map(
<button (p) => (
key={p} <button
className={`${shared.roleBtn} ${platformFilter === p ? shared.roleBtnActive : ''}`} key={p}
onClick={() => setPlatformFilter(p)} className={`${shared.roleBtn} ${platformFilter === p ? shared.roleBtnActive : ''}`}
> onClick={() => setPlatformFilter(p)}
{PLATFORM_FILTER_LABELS[p]} >
</button> {PLATFORM_FILTER_LABELS[p]}
))} </button>
</div> )
)}
</div>
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
{(['all', 'unlocked', 'locked'] as const).map(s => ( {(['all', 'unlocked', 'locked'] as const).map((s) => (
<button <button
key={s} key={s}
className={`${shared.roleBtn} ${statusFilter === s ? shared.roleBtnActive : ''}`} className={`${shared.roleBtn} ${statusFilter === s ? shared.roleBtnActive : ''}`}
onClick={() => setStatusFilter(s)} onClick={() => setStatusFilter(s)}
> >
{s} {s}
</button> </button>
))} ))}
</div> </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={handleUnlockShown}> <button className={shared.unlockAllBtn} onClick={handleUnlockShown}>
Unlock Shown Unlock Shown
</button> </button>
<button className={shared.lockAllBtn} onClick={handleLockShown}> <button className={shared.lockAllBtn} onClick={handleLockShown}>
Lock Shown Lock Shown
</button> </button>
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={shared.empty}>No DLCs match</div> <div className={shared.empty}>No DLCs match</div>
) : ( ) : (
<div className={styles.grid}> <div className={styles.grid}>
{filtered.map(dlc => { {filtered.map((dlc) => {
const unlocked = store.unlockedDLCs.includes(dlc.id); const unlocked = store.unlockedDLCs.includes(dlc.id);
return ( return (
<div <div
key={dlc.id} key={dlc.id}
className={`${styles.card} ${unlocked ? styles.cardUnlocked : ''}`} className={`${styles.card} ${unlocked ? styles.cardUnlocked : ''}`}
onClick={() => handleToggle(dlc.id)} onClick={() => handleToggle(dlc.id)}
> >
<span className={styles.cardName}>{dlc.name}</span> <span className={styles.cardName}>{dlc.name}</span>
<div className={styles.platforms}> <div className={styles.platforms}>
<span className={`${styles.badge} ${isOnSteam(dlc) ? styles.badgeSteam : styles.badgeOff}`}>ST</span> <span
<span className={`${styles.badge} ${isOnEpic(dlc) ? styles.badgeEpic : styles.badgeOff}`}>EP</span> className={`${styles.badge} ${isOnSteam(dlc) ? styles.badgeSteam : styles.badgeOff}`}
<span className={`${styles.badge} ${isOnXbox(dlc) ? styles.badgeXbox : styles.badgeOff}`}>XB</span> >
</div> ST
</div> </span>
); <span
})} className={`${styles.badge} ${isOnEpic(dlc) ? styles.badgeEpic : styles.badgeOff}`}
</div> >
)} EP
</div> </span>
); <span
className={`${styles.badge} ${isOnXbox(dlc) ? styles.badgeXbox : styles.badgeOff}`}
>
XB
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
} }
+9 -9
View File
@@ -1,20 +1,20 @@
import { DLC } from "@/lib/utils"; import { DLC } from '@/lib/utils';
export type PlatformFilter = 'all' | 'steam' | 'epic' | 'xbox'; export type PlatformFilter = 'all' | 'steam' | 'epic' | 'xbox';
export const isOnSteam = (dlc: DLC) => 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) => 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) => 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) => { export const matchesPlatform = (dlc: DLC, filter: PlatformFilter) => {
if (filter === 'all') return true; if (filter === 'all') return true;
if (filter === 'steam') return isOnSteam(dlc); if (filter === 'steam') return isOnSteam(dlc);
if (filter === 'epic') return isOnEpic(dlc); if (filter === 'epic') return isOnEpic(dlc);
if (filter === 'xbox') return isOnXbox(dlc); if (filter === 'xbox') return isOnXbox(dlc);
return true; return true;
}; };
+22 -17
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 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 { body {
background: #050505; background: #050505;
color: #c9c9c9; color: #c9c9c9;
font-family: 'Roboto Condensed', sans-serif; font-family: 'Roboto Condensed', sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
h1, h2, h3, h4, h5, h6 { h1,
font-family: 'Oswald', sans-serif; h2,
h3,
h4,
h5,
h6 {
font-family: 'Oswald', sans-serif;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #050505; background: #050505;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #330000; background: #330000;
border-radius: 3px; border-radius: 3px;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
} }
::-webkit-scrollbar-thumb:hover { ::-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'; import { Addon, getAddonIconUrl, randInRange } from './types';
type Props = { type Props = {
addons: Addon[]; addons: Addon[];
quantities: Record<string, number>; quantities: Record<string, number>;
randMin: number; randMin: number;
randMax: number; randMax: number;
onSetQty: (id: string, qty: number) => void; onSetQty: (id: string, qty: number) => void;
}; };
export default function AddonGrid({ addons, quantities, randMin, randMax, onSetQty }: Props) { export default function AddonGrid({
const [search, setSearch] = useState(''); addons,
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>('all'); quantities,
randMin,
randMax,
onSetQty
}: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>(
'all'
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return addons.filter(addon => { return addons.filter((addon) => {
if (roleFilter === 'killer' && addon.role !== 1) return false; if (roleFilter === 'killer' && addon.role !== 1) return false;
if (roleFilter === 'survivor' && addon.role !== 2) return false; if (roleFilter === 'survivor' && addon.role !== 2) return false;
if (search.trim() && !addon.name.toLowerCase().includes(search.toLowerCase())) return false; if (
return true; search.trim() &&
}); !addon.name.toLowerCase().includes(search.toLowerCase())
}, [addons, search, roleFilter]); )
return false;
return true;
});
}, [addons, search, roleFilter]);
const handleGive100Visible = () => filtered.forEach(a => onSetQty(a.id, 100)); const handleGive100Visible = () =>
const handleRandVisible = () => filtered.forEach(a => onSetQty(a.id, randInRange(randMin, randMax))); filtered.forEach((a) => onSetQty(a.id, 100));
const handleLockVisible = () => filtered.forEach(a => onSetQty(a.id, 0)); 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 ( return (
<> <>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder="Search addons..." placeholder='Search addons...'
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
<button className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('all')}>All</button> <button
<button className={`${shared.roleBtn} ${roleFilter === 'survivor' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('survivor')}>Survivor</button> className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`}
<button className={`${shared.roleBtn} ${roleFilter === 'killer' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('killer')}>Killer</button> onClick={() => setRoleFilter('all')}
</div> >
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.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active out of {addons.length} total</span> <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={shared.unlockAllBtn} onClick={handleGive100Visible}>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button> Set all to 100
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button> </button>
</div> <button className={styles.randBtn} onClick={handleRandVisible}>
Randomize
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Remove all visible
</button>
</div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={shared.empty}>No addons match</div> <div className={shared.empty}>No addons match</div>
) : ( ) : (
<div className={styles.grid}> <div className={styles.grid}>
{filtered.map(addon => ( {filtered.map((addon) => (
<QuantityCard <QuantityCard
key={addon.id} key={addon.id}
id={addon.id} id={addon.id}
name={addon.name} name={addon.name}
iconUrl={getAddonIconUrl(addon.iconFilePath)} iconUrl={getAddonIconUrl(addon.iconFilePath)}
qty={quantities[addon.id] ?? 0} qty={quantities[addon.id] ?? 0}
randMin={randMin} randMin={randMin}
randMax={randMax} randMax={randMax}
onSetQty={onSetQty} onSetQty={onSetQty}
/> />
))} ))}
</div> </div>
)} )}
</> </>
); );
} }
+94 -67
View File
@@ -4,84 +4,111 @@ import { useState, useMemo } from 'react';
import shared from '../../styles/shared.module.css'; import shared from '../../styles/shared.module.css';
import styles from '../../styles/Items.module.css'; import styles from '../../styles/Items.module.css';
import QuantityCard from '../../components/QuantityCard'; 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'; import { randInRange } from '@/lib/utils';
type Props = { type Props = {
items: Item[]; items: Item[];
quantities: Record<string, number>; quantities: Record<string, number>;
randMin: number; randMin: number;
randMax: number; randMax: number;
onSetQty: (id: string, qty: number) => void; onSetQty: (id: string, qty: number) => void;
}; };
export default function ItemGrid({ items, quantities, randMin, randMax, onSetQty }: Props) { export default function ItemGrid({
const [search, setSearch] = useState(''); items,
const [typeFilter, setTypeFilter] = useState<ItemType>('all'); quantities,
randMin,
randMax,
onSetQty
}: Props) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ItemType>('all');
const filtered = useMemo(() => { const filtered = useMemo(() => {
return items.filter(item => { return items.filter((item) => {
if (typeFilter !== 'all' && getItemType(item.id) !== typeFilter) return false; if (typeFilter !== 'all' && getItemType(item.id) !== typeFilter)
if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false; return false;
return true; if (
}); search.trim() &&
}, [items, typeFilter, search]); !item.name.toLowerCase().includes(search.toLowerCase())
)
return false;
return true;
});
}, [items, typeFilter, search]);
const handleGive100Visible = () => filtered.forEach(i => onSetQty(i.id, 100)); const handleGive100Visible = () =>
const handleRandVisible = () => filtered.forEach(i => onSetQty(i.id, randInRange(randMin, randMax))); filtered.forEach((i) => onSetQty(i.id, 100));
const handleLockVisible = () => filtered.forEach(i => onSetQty(i.id, 0)); 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 ( return (
<> <>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder="Search items..." placeholder='Search items...'
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
{(Object.keys(ITEM_TYPE_LABELS) as ItemType[]).map(t => ( {(Object.keys(ITEM_TYPE_LABELS) as ItemType[]).map((t) => (
<button <button
key={t} key={t}
className={`${shared.roleBtn} ${typeFilter === t ? shared.roleBtnActive : ''}`} className={`${shared.roleBtn} ${typeFilter === t ? shared.roleBtnActive : ''}`}
onClick={() => setTypeFilter(t)} onClick={() => setTypeFilter(t)}
> >
{ITEM_TYPE_LABELS[t]} {ITEM_TYPE_LABELS[t]}
</button> </button>
))} ))}
</div> </div>
<span className={shared.spacer} /> <span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active</span> <span className={shared.resultCount}>
{filtered.length} shown · {activeCount} active
</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button> <button className={shared.unlockAllBtn} onClick={handleGive100Visible}>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button> Set all to 100
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button> </button>
</div> <button className={styles.randBtn} onClick={handleRandVisible}>
Randomize
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Remove all visible
</button>
</div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={shared.empty}>No items match</div> <div className={shared.empty}>No items match</div>
) : ( ) : (
<div className={styles.grid}> <div className={styles.grid}>
{filtered.map(item => ( {filtered.map((item) => (
<QuantityCard <QuantityCard
key={item.id} key={item.id}
id={item.id} id={item.id}
name={item.name} name={item.name}
iconUrl={getItemIconUrl(item.iconFilePath)} iconUrl={getItemIconUrl(item.iconFilePath)}
qty={quantities[item.id] ?? 0} qty={quantities[item.id] ?? 0}
randMin={randMin} randMin={randMin}
randMax={randMax} randMax={randMax}
onSetQty={onSetQty} onSetQty={onSetQty}
/> />
))} ))}
</div> </div>
)} )}
</> </>
); );
} }
+92 -72
View File
@@ -8,92 +8,112 @@ import { Offering, OfferingRole, getOfferingIconUrl } from './types';
import { randInRange } from '@/lib/utils'; import { randInRange } from '@/lib/utils';
type Props = { type Props = {
offerings: Offering[]; offerings: Offering[];
quantities: Record<string, number>; quantities: Record<string, number>;
randMin: number; randMin: number;
randMax: number; randMax: number;
onSetQty: (id: string, qty: number) => void; onSetQty: (id: string, qty: number) => void;
}; };
const ROLE_LABELS: Record<OfferingRole, string> = { 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) => { const roleMatches = (offeringRole: number, filter: OfferingRole) => {
if (filter === 'all') return true; if (filter === 'all') return true;
if (filter === 'shared') return offeringRole === 0; if (filter === 'shared') return offeringRole === 0;
if (filter === 'killer') return offeringRole === 1; if (filter === 'killer') return offeringRole === 1;
if (filter === 'survivor') return offeringRole === 2; if (filter === 'survivor') return offeringRole === 2;
return true; return true;
}; };
export default function OfferingGrid({ offerings, quantities, randMin, randMax, onSetQty }: Props) { export default function OfferingGrid({
const [search, setSearch] = useState(''); offerings,
const [roleFilter, setRoleFilter] = useState<OfferingRole>('all'); quantities,
randMin,
randMax,
onSetQty
}: Props) {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<OfferingRole>('all');
const filtered = useMemo(() => { const filtered = useMemo(() => {
return offerings.filter(o => { return offerings.filter((o) => {
if (!roleMatches(o.role, roleFilter)) return false; if (!roleMatches(o.role, roleFilter)) return false;
if (search.trim() && !o.name.toLowerCase().includes(search.toLowerCase())) return false; if (search.trim() && !o.name.toLowerCase().includes(search.toLowerCase()))
return true; return false;
}); return true;
}, [offerings, roleFilter, search]); });
}, [offerings, roleFilter, search]);
const handleGive100Visible = () => filtered.forEach(o => onSetQty(o.id, 100)); const handleGive100Visible = () =>
const handleRandVisible = () => filtered.forEach(o => onSetQty(o.id, randInRange(randMin, randMax))); filtered.forEach((o) => onSetQty(o.id, 100));
const handleLockVisible = () => filtered.forEach(o => onSetQty(o.id, 0)); 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 ( return (
<> <>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input <input
className={shared.searchInput} className={shared.searchInput}
type="text" type='text'
placeholder="Search offerings..." placeholder='Search offerings...'
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
{(Object.keys(ROLE_LABELS) as OfferingRole[]).map(r => ( {(Object.keys(ROLE_LABELS) as OfferingRole[]).map((r) => (
<button <button
key={r} key={r}
className={`${shared.roleBtn} ${roleFilter === r ? shared.roleBtnActive : ''}`} className={`${shared.roleBtn} ${roleFilter === r ? shared.roleBtnActive : ''}`}
onClick={() => setRoleFilter(r)} onClick={() => setRoleFilter(r)}
> >
{ROLE_LABELS[r]} {ROLE_LABELS[r]}
</button> </button>
))} ))}
</div> </div>
<span className={shared.spacer} /> <span className={shared.spacer} />
<span className={shared.resultCount}>{filtered.length} shown · {activeCount} active</span> <span className={shared.resultCount}>
{filtered.length} shown · {activeCount} active
</span>
<button className={shared.unlockAllBtn} onClick={handleGive100Visible}>Set all to 100</button> <button className={shared.unlockAllBtn} onClick={handleGive100Visible}>
<button className={styles.randBtn} onClick={handleRandVisible}>Randomize</button> Set all to 100
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Remove all visible</button> </button>
</div> <button className={styles.randBtn} onClick={handleRandVisible}>
Randomize
</button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Remove all visible
</button>
</div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={shared.empty}>No offerings match</div> <div className={shared.empty}>No offerings match</div>
) : ( ) : (
<div className={styles.grid}> <div className={styles.grid}>
{filtered.map(offering => ( {filtered.map((offering) => (
<QuantityCard <QuantityCard
key={offering.id} key={offering.id}
id={offering.id} id={offering.id}
name={offering.name} name={offering.name}
iconUrl={getOfferingIconUrl(offering.iconFilePath)} iconUrl={getOfferingIconUrl(offering.iconFilePath)}
qty={quantities[offering.id] ?? 0} qty={quantities[offering.id] ?? 0}
randMin={randMin} randMin={randMin}
randMax={randMax} randMax={randMax}
onSetQty={onSetQty} onSetQty={onSetQty}
/> />
))} ))}
</div> </div>
)} )}
</> </>
); );
} }
+104 -94
View File
@@ -15,110 +15,120 @@ import AddonGrid from './AddonGrid';
type Tab = 'items' | 'offerings' | 'addons'; type Tab = 'items' | 'offerings' | 'addons';
export default function ItemsPage() { export default function ItemsPage() {
const store = useInventoryStore(); const store = useInventoryStore();
const [tab, setTab] = useState<Tab>('items'); const [tab, setTab] = useState<Tab>('items');
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [offerings, setOfferings] = useState<Offering[]>([]); const [offerings, setOfferings] = useState<Offering[]>([]);
const [addons, setAddons] = useState<Addon[]>([]); const [addons, setAddons] = useState<Addon[]>([]);
const [randMin, setRandMin] = useState(50); const [randMin, setRandMin] = useState(50);
const [randMax, setRandMax] = useState(200); const [randMax, setRandMax] = useState(200);
useEffect(() => { useEffect(() => {
Promise.all([fetchItems(), fetchOfferings(), fetchAddons()]) Promise.all([fetchItems(), fetchOfferings(), fetchAddons()]).then(
.then(([i, o, a]) => { ([i, o, a]) => {
setItems(i); setItems(i);
setOfferings(o); setOfferings(o);
setAddons(a); setAddons(a);
}); }
}, []); );
}, []);
const handleClearAll = () => { const handleClearAll = () => {
store.clearCategory('items'); store.clearCategory('items');
store.clearCategory('offerings'); store.clearCategory('offerings');
store.clearCategory('addons'); store.clearCategory('addons');
}; };
const activeItems = Object.values(store.items).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 activeOfferings = Object.values(store.offerings).filter(
const activeAddons = Object.values(store.addons).filter(q => q > 0).length; (q) => q > 0
).length;
const activeAddons = Object.values(store.addons).filter((q) => q > 0).length;
return ( return (
<div className={shared.container}> <div className={shared.container}>
<header className={shared.header}> <header className={shared.header}>
<div> <div>
<h1 className={shared.title}>Items & Offerings</h1> <h1 className={shared.title}>Items & Offerings</h1>
<p className={shared.subtitle}> <p className={shared.subtitle}>
{activeItems} items · {activeOfferings} offerings · {activeAddons} addons {activeItems} items · {activeOfferings} offerings · {activeAddons}{' '}
</p> addons
</div> </p>
</div>
<div className={styles.rangeGroup}> <div className={styles.rangeGroup}>
<span className={styles.rangeLabel}>Rand range</span> <span className={styles.rangeLabel}>Rand range</span>
<input <input
className={styles.rangeInput} className={styles.rangeInput}
type="number" type='number'
value={randMin} value={randMin}
min={0} min={0}
max={5000} max={5000}
onChange={e => setRandMin(Math.max(0, parseInt(e.target.value) || 0))} onChange={(e) =>
/> setRandMin(Math.max(0, parseInt(e.target.value) || 0))
<span className={styles.rangeSep}></span> }
<input />
className={styles.rangeInput} <span className={styles.rangeSep}></span>
type="number" <input
value={randMax} className={styles.rangeInput}
min={0} type='number'
max={5000} value={randMax}
onChange={e => setRandMax(Math.min(5000, parseInt(e.target.value) || 0))} min={0}
/> max={5000}
</div> onChange={(e) =>
setRandMax(Math.min(5000, parseInt(e.target.value) || 0))
}
/>
</div>
<button className={shared.clearBtn} onClick={handleClearAll}>Clear All</button> <button className={shared.clearBtn} onClick={handleClearAll}>
</header> Clear All
</button>
</header>
<div className={styles.tabs}> <div className={styles.tabs}>
{(['items', 'offerings', 'addons'] as Tab[]).map(t => ( {(['items', 'offerings', 'addons'] as Tab[]).map((t) => (
<button <button
key={t} key={t}
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`} className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
onClick={() => setTab(t)} onClick={() => setTab(t)}
> >
{t} {t}
</button> </button>
))} ))}
</div> </div>
{tab === 'items' && ( {tab === 'items' && (
<ItemGrid <ItemGrid
items={items} items={items}
quantities={store.items} quantities={store.items}
randMin={randMin} randMin={randMin}
randMax={randMax} randMax={randMax}
onSetQty={store.setItemQuantity} onSetQty={store.setItemQuantity}
/> />
)} )}
{tab === 'offerings' && ( {tab === 'offerings' && (
<OfferingGrid <OfferingGrid
offerings={offerings} offerings={offerings}
quantities={store.offerings} quantities={store.offerings}
randMin={randMin} randMin={randMin}
randMax={randMax} randMax={randMax}
onSetQty={store.setOfferingQuantity} onSetQty={store.setOfferingQuantity}
/> />
)} )}
{tab === 'addons' && ( {tab === 'addons' && (
<AddonGrid <AddonGrid
addons={addons} addons={addons}
quantities={store.addons} quantities={store.addons}
randMin={randMin} randMin={randMin}
randMax={randMax} randMax={randMax}
onSetQty={store.setAddonQuantity} onSetQty={store.setAddonQuantity}
/> />
)} )}
</div> </div>
); );
} }
+41 -29
View File
@@ -1,59 +1,71 @@
import { DB_BASE_URL } from '../../lib/db'; import { DB_BASE_URL } from '../../lib/db';
export type Item = { export type Item = {
id: string; id: string;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
}; };
export type Offering = { export type Offering = {
id: string; id: string;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
role: number; role: number;
}; };
export type Addon = { export type Addon = {
id: string; id: string;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
role: number; 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 type OfferingRole = 'all' | 'shared' | 'killer' | 'survivor';
export const ITEM_TYPE_LABELS: Record<ItemType, string> = { export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
all: 'All', toolbox: 'Toolbox', flashlight: 'Flashlight', all: 'All',
medkit: 'Med-Kit', key: 'Key', map: 'Map', other: 'Other', toolbox: 'Toolbox',
flashlight: 'Flashlight',
medkit: 'Med-Kit',
key: 'Key',
map: 'Map',
other: 'Other'
}; };
export const getItemType = (id: string): ItemType => { export const getItemType = (id: string): ItemType => {
if (/toolbox/i.test(id)) return 'toolbox'; if (/toolbox/i.test(id)) return 'toolbox';
if (/flashlight/i.test(id)) return 'flashlight'; if (/flashlight/i.test(id)) return 'flashlight';
if (/medkit|med_?kit/i.test(id)) return 'medkit'; if (/medkit|med_?kit/i.test(id)) return 'medkit';
if (/key/i.test(id)) return 'key'; if (/key/i.test(id)) return 'key';
if (/map/i.test(id)) return 'map'; if (/map/i.test(id)) return 'map';
return 'other'; return 'other';
}; };
export const getItemIconUrl = (iconFilePath: string) => { export const getItemIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0]; const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/item-icons/${file}.png`; return `${DB_BASE_URL}/icons/item-icons/${file}.png`;
}; };
export const getOfferingIconUrl = (iconFilePath: string) => { export const getOfferingIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0]; const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/offering-icons/${file}.png`; return `${DB_BASE_URL}/icons/offering-icons/${file}.png`;
}; };
export const getAddonIconUrl = (iconFilePath: string) => { export const getAddonIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0]; const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/addon-icons/${file}.png`; return `${DB_BASE_URL}/icons/addon-icons/${file}.png`;
}; };
export const randInRange = (min: number, max: number) => { export const randInRange = (min: number, max: number) => {
const lo = Math.min(min, max); const lo = Math.min(min, max);
const hi = Math.max(min, max); const hi = Math.max(min, max);
return Math.floor(Math.random() * (hi - lo + 1)) + lo; return Math.floor(Math.random() * (hi - lo + 1)) + lo;
}; };
+21 -23
View File
@@ -1,31 +1,29 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import Sidebar from "../components/Sidebar"; import Sidebar from '../components/Sidebar';
import styles from "../styles/Layout.module.css"; import styles from '../styles/Layout.module.css';
import "./globals.css"; import './globals.css';
import AppContainer from "@/components/Container"; import AppContainer from '@/components/Container';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Hex: Unlocked", title: 'Hex: Unlocked',
description: "", description: ''
}; };
export default function RootLayout({ export default function RootLayout({
children, children
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang='en'>
<body> <body>
<AppContainer> <AppContainer>
<div className={styles.layoutContainer}> <div className={styles.layoutContainer}>
<Sidebar /> <Sidebar />
<main className={styles.mainContent}> <main className={styles.mainContent}>{children}</main>
{children} </div>
</main> </AppContainer>
</div> </body>
</AppContainer> </html>
</body> );
</html>
);
} }
+258 -173
View File
@@ -4,202 +4,287 @@ import { useState, useEffect, useMemo } from 'react';
import { useInventoryStore } from '@/store/useInventoryStore'; import { useInventoryStore } from '@/store/useInventoryStore';
import styles from '../styles/Home.module.css'; import styles from '../styles/Home.module.css';
import { isNamedDLC } from '@/lib/utils'; 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() { export default function Home() {
const store = useInventoryStore(); const store = useInventoryStore();
const [charCount, setCharCount] = useState(0); const [charCount, setCharCount] = useState(0);
const [custCount, setCustCount] = useState(0); const [custCount, setCustCount] = useState(0);
const [itemsCount, setItemsCount] = useState(0); const [itemsCount, setItemsCount] = useState(0);
const [offeringsCount, setOfferingsCount] = useState(0); const [offeringsCount, setOfferingsCount] = useState(0);
const [dlcsCount, setDlcsCount] = useState(0); const [dlcsCount, setDlcsCount] = useState(0);
const [addonsCount, setAddonsCount] = useState(0); const [addonsCount, setAddonsCount] = useState(0);
const [perksCount, setPerksCount] = useState(0); const [perksCount, setPerksCount] = useState(0);
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState('');
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
fetchCharacters(), fetchCharacters(),
fetchItems(), fetchItems(),
fetchOfferings(), fetchOfferings(),
fetchCustomizations(), fetchCustomizations(),
fetchDLCs(), fetchDLCs(),
fetchAddons(), fetchAddons(),
fetchPerks() fetchPerks()
]).then(([chars, items, offerings, customizations, dlcs, addons, perks]) => { ]).then(
setCharCount(chars.length); ([chars, items, offerings, customizations, dlcs, addons, perks]) => {
setItemsCount(items.length); setCharCount(chars.length);
setOfferingsCount(offerings.length); setItemsCount(items.length);
setCustCount(customizations.length); setOfferingsCount(offerings.length);
setDlcsCount(dlcs.filter(isNamedDLC).length); setCustCount(customizations.length);
setAddonsCount(addons.length); setDlcsCount(dlcs.filter(isNamedDLC).length);
setPerksCount(perks.length); setAddonsCount(addons.length);
}); setPerksCount(perks.length);
}, []); }
);
}, []);
/* /*
profile handling profile handling
*/ */
const handleDownload = () => { const handleDownload = () => {
const blob = new Blob([importText], { type: 'application/json' }); const blob = new Blob([importText], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = 'profile.json'; link.download = 'profile.json';
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(importText); navigator.clipboard.writeText(importText);
}; };
const getExportText = () => { const getExportText = () => {
return JSON.stringify({ return JSON.stringify(
unlockedCharacters: store.unlockedCharacters, {
unlockedCustomizations: store.unlockedCustomizations, unlockedCharacters: store.unlockedCharacters,
unlockedDLCs: store.unlockedDLCs, unlockedCustomizations: store.unlockedCustomizations,
unlockedPerks: store.unlockedPerks, unlockedDLCs: store.unlockedDLCs,
items: store.items, unlockedPerks: store.unlockedPerks,
offerings: store.offerings, items: store.items,
addons: store.addons offerings: store.offerings,
}, null, 2); addons: store.addons
}; },
null,
2
);
};
const handleImport = async () => { const handleImport = async () => {
try { try {
const parsed = JSON.parse(importText); const parsed = JSON.parse(importText);
store.importProfile(parsed); store.importProfile(parsed);
} catch (e) { } catch (e) {
console.error("Invalid JSON", e); console.error('Invalid JSON', e);
} }
}; };
useEffect(() => { useEffect(() => {
setImportText(getExportText()); setImportText(getExportText());
}, [store.unlockedCharacters, store.unlockedCustomizations, store.unlockedDLCs, store.unlockedPerks, store.items, store.offerings, store.addons]); }, [
store.unlockedCharacters,
store.unlockedCustomizations,
store.unlockedDLCs,
store.unlockedPerks,
store.items,
store.offerings,
store.addons
]);
/* /*
stats stats
*/ */
const unlockedItems = useMemo(() => Object.values(store.items).filter(qty => qty > 0).length, [store.items]); const unlockedItems = useMemo(
const unlockedOfferings = useMemo(() => Object.values(store.offerings).filter(qty => qty > 0).length, [store.offerings]); () => Object.values(store.items).filter((qty) => qty > 0).length,
const unlockedAddons = useMemo(() => Object.values(store.addons).filter(qty => qty > 0).length, [store.addons]); [store.items]
const unlockedPerks = store.unlockedPerks.length; );
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}> return (
<header className={styles.header}> <div className={styles.container}>
<h1 className={styles.title}>Dashboard</h1> <header className={styles.header}>
<p className={styles.subtitle}>Status overview and profile management</p> <h1 className={styles.title}>Dashboard</h1>
</header> <p className={styles.subtitle}>
Status overview and profile management
</p>
</header>
{/* stats cards */} {/* stats cards */}
<section className={styles.statsGrid}> <section className={styles.statsGrid}>
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statLabel}>Characters</div> <div className={styles.statLabel}>Characters</div>
<div className={styles.statValue}>{store.unlockedCharacters.length} <span className={styles.statTotal}>/ {charCount || '-'}</span></div> <div className={styles.statValue}>
</div> {store.unlockedCharacters.length}{' '}
<div className={styles.statCard}> <span className={styles.statTotal}>/ {charCount || '-'}</span>
<div className={styles.statLabel}>Customizations</div> </div>
<div className={styles.statValue}>{store.unlockedCustomizations.length} <span className={styles.statTotal}>/ {custCount || '-'}</span></div> </div>
</div> <div className={styles.statCard}>
<div className={styles.statCard}> <div className={styles.statLabel}>Customizations</div>
<div className={styles.statLabel}>DLCs</div> <div className={styles.statValue}>
<div className={styles.statValue}>{store.unlockedDLCs.length} <span className={styles.statTotal}>/ {dlcsCount || '-'}</span></div> {store.unlockedCustomizations.length}{' '}
</div> <span className={styles.statTotal}>/ {custCount || '-'}</span>
<div className={styles.statCard}> </div>
<div className={styles.statLabel}>Items</div> </div>
<div className={styles.statValue}>{unlockedItems} <span className={styles.statTotal}>/ {itemsCount || '-'}</span></div> <div className={styles.statCard}>
</div> <div className={styles.statLabel}>DLCs</div>
<div className={styles.statCard}> <div className={styles.statValue}>
<div className={styles.statLabel}>Offerings</div> {store.unlockedDLCs.length}{' '}
<div className={styles.statValue}>{unlockedOfferings} <span className={styles.statTotal}>/ {offeringsCount || '-'}</span></div> <span className={styles.statTotal}>/ {dlcsCount || '-'}</span>
</div> </div>
<div className={styles.statCard}> </div>
<div className={styles.statLabel}>Addons</div> <div className={styles.statCard}>
<div className={styles.statValue}>{unlockedAddons} <span className={styles.statTotal}>/ {addonsCount || '-'}</span></div> <div className={styles.statLabel}>Items</div>
</div> <div className={styles.statValue}>
<div className={styles.statCard}> {unlockedItems}{' '}
<div className={styles.statLabel}>Perks</div> <span className={styles.statTotal}>/ {itemsCount || '-'}</span>
<div className={styles.statValue}>{unlockedPerks} <span className={styles.statTotal}>/ {perksCount || '-'}</span></div> </div>
</div> </div>
</section> <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 */} {/* toggles */}
<section className={styles.panelGrid}> <section className={styles.panelGrid}>
<div className={styles.card}> <div className={styles.card}>
<h3 className={styles.cardTitle}>Spoofer Toggles</h3> <h3 className={styles.cardTitle}>Spoofer Toggles</h3>
<div className={styles.toggleRow}> <div className={styles.toggleRow}>
<div className={styles.toggleInfo}> <div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Item Spoofing</span> <span className={styles.toggleLabel}>Item Spoofing</span>
<span className={styles.toggleDesc}>Spoof inventory items, addons, and offerings</span> <span className={styles.toggleDesc}>
</div> Spoof inventory items, addons, and offerings
<label className={styles.switch}> </span>
<input type="checkbox" checked={store.spoofItems} onChange={(e) => store.setToggle('spoofItems', e.target.checked)} /> </div>
<span className={styles.slider}></span> <label className={styles.switch}>
</label> <input
</div> 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.toggleRow}>
<div className={styles.toggleInfo}> <div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Perk Spoofing</span> <span className={styles.toggleLabel}>Perk Spoofing</span>
<span className={styles.toggleDesc}>Unlock all perk slots and custom builds</span> <span className={styles.toggleDesc}>
</div> Unlock all perk slots and custom builds
<label className={styles.switch}> </span>
<input type="checkbox" checked={store.spoofPerks} onChange={(e) => store.setToggle('spoofPerks', e.target.checked)} /> </div>
<span className={styles.slider}></span> <label className={styles.switch}>
</label> <input
</div> 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.toggleRow}>
<div className={styles.toggleInfo}> <div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>Catalog Spoofing</span> <span className={styles.toggleLabel}>Catalog Spoofing</span>
<span className={styles.toggleDesc}>Unlock cosmetics, characters, and outfits</span> <span className={styles.toggleDesc}>
</div> Unlock cosmetics, characters, and outfits
<label className={styles.switch}> </span>
<input type="checkbox" checked={store.spoofCatalog} onChange={(e) => store.setToggle('spoofCatalog', e.target.checked)} /> </div>
<span className={styles.slider}></span> <label className={styles.switch}>
</label> <input
</div> 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.toggleRow}>
<div className={styles.toggleInfo}> <div className={styles.toggleInfo}>
<span className={styles.toggleLabel}>DLC Spoofing</span> <span className={styles.toggleLabel}>DLC Spoofing</span>
<span className={styles.toggleDesc}>Bypass Steam, Epic, and Xbox DLC ownership</span> <span className={styles.toggleDesc}>
</div> Bypass Steam, Epic, and Xbox DLC ownership
<label className={styles.switch}> </span>
<input type="checkbox" checked={store.spoofDLCs} onChange={(e) => store.setToggle('spoofDLCs', e.target.checked)} /> </div>
<span className={styles.slider}></span> <label className={styles.switch}>
</label> <input
</div> type='checkbox'
</div> checked={store.spoofDLCs}
onChange={(e) => store.setToggle('spoofDLCs', e.target.checked)}
/>
<span className={styles.slider}></span>
</label>
</div>
</div>
{/* profile */} {/* profile */}
<div className={styles.card}> <div className={styles.card}>
<h3 className={styles.cardTitle}>Profile Import / Export</h3> <h3 className={styles.cardTitle}>Profile Import / Export</h3>
<textarea <textarea
className={styles.textarea} className={styles.textarea}
value={importText} value={importText}
onChange={(e) => setImportText(e.target.value)} onChange={(e) => setImportText(e.target.value)}
/> />
<div className={styles.btnRow}> <div className={styles.btnRow}>
<button className={styles.primaryBtn} onClick={handleImport}> <button className={styles.primaryBtn} onClick={handleImport}>
Validate & Import Validate & Import
</button> </button>
<button className={styles.secondaryBtn} onClick={handleCopy}> <button className={styles.secondaryBtn} onClick={handleCopy}>
Copy to Clipboard Copy to Clipboard
</button> </button>
<button className={styles.secondaryBtn} onClick={handleDownload}> <button className={styles.secondaryBtn} onClick={handleDownload}>
Download file Download file
</button> </button>
</div> </div>
</div>
</div> </section>
</section> </div>
</div>) );
} }
+110 -66
View File
@@ -8,79 +8,123 @@ import { fetchPerks } from '../../lib/db';
import { getPerkIconUrl, Perk } from './types'; import { getPerkIconUrl, Perk } from './types';
export default function PerksPage() { export default function PerksPage() {
const store = useInventoryStore(); const store = useInventoryStore();
const [perks, setPerks] = useState<Perk[]>([]); const [perks, setPerks] = useState<Perk[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>('all'); const [roleFilter, setRoleFilter] = useState<'all' | 'killer' | 'survivor'>(
'all'
);
useEffect(() => { useEffect(() => {
fetchPerks().then(setPerks); fetchPerks().then(setPerks);
}, []); }, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return perks.filter(p => { return perks.filter((p) => {
if (roleFilter === 'killer' && p.role !== 1) return false; if (roleFilter === 'killer' && p.role !== 1) return false;
if (roleFilter === 'survivor' && p.role !== 2) return false; if (roleFilter === 'survivor' && p.role !== 2) return false;
if (search.trim() && !p.name.toLowerCase().includes(search.toLowerCase())) return false; if (search.trim() && !p.name.toLowerCase().includes(search.toLowerCase()))
return true; return false;
}); return true;
}, [perks, search, roleFilter]); });
}, [perks, search, roleFilter]);
const handleClearAll = () => store.clearCategory('perks'); const handleClearAll = () => store.clearCategory('perks');
const handleUnlockVisible = () => store.unlockAllInCategory('perks', Array.from(new Set([...store.unlockedPerks, ...filtered.map(p => p.id)]))); const handleUnlockVisible = () =>
const handleLockVisible = () => store.unlockAllInCategory('perks', store.unlockedPerks.filter(id => !filtered.find(p => p.id === id))); 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 ( return (
<div className={shared.container}> <div className={shared.container}>
<header className={shared.header}> <header className={shared.header}>
<div> <div>
<h1 className={shared.title}>Perks</h1> <h1 className={shared.title}>Perks</h1>
<p className={shared.subtitle}>{activeCount} active out of {perks.length} total</p> <p className={shared.subtitle}>
</div> {activeCount} active out of {perks.length} total
<button className={shared.clearBtn} onClick={handleClearAll}>Clear All</button> </p>
</header> </div>
<button className={shared.clearBtn} onClick={handleClearAll}>
Clear All
</button>
</header>
<div className={shared.toolbar}> <div className={shared.toolbar}>
<input className={shared.searchInput} type="text" placeholder="Search perks..." value={search} onChange={e => setSearch(e.target.value)} /> <input
className={shared.searchInput}
type='text'
placeholder='Search perks...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={shared.roleFilter}> <div className={shared.roleFilter}>
<button className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('all')}>All</button> <button
<button className={`${shared.roleBtn} ${roleFilter === 'survivor' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('survivor')}>Survivor</button> className={`${shared.roleBtn} ${roleFilter === 'all' ? shared.roleBtnActive : ''}`}
<button className={`${shared.roleBtn} ${roleFilter === 'killer' ? shared.roleBtnActive : ''}`} onClick={() => setRoleFilter('killer')}>Killer</button> onClick={() => setRoleFilter('all')}
</div> >
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.spacer} />
<span className={shared.resultCount}>{filtered.length} shown</span> <span className={shared.resultCount}>{filtered.length} shown</span>
<button className={shared.unlockAllBtn} onClick={handleUnlockVisible}>Unlock visible</button> <button className={shared.unlockAllBtn} onClick={handleUnlockVisible}>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>Lock visible</button> Unlock visible
</div> </button>
<button className={shared.lockAllBtn} onClick={handleLockVisible}>
Lock visible
</button>
</div>
<div className={styles.grid}> <div className={styles.grid}>
{filtered.map(perk => { {filtered.map((perk) => {
const unlocked = store.unlockedPerks.includes(perk.id); const unlocked = store.unlockedPerks.includes(perk.id);
const killer = perk.role === 1; const killer = perk.role === 1;
return ( return (
<div <div
key={perk.id} key={perk.id}
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`} className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
onClick={() => store.toggleItem(perk.id, 'perks')} onClick={() => store.toggleItem(perk.id, 'perks')}
> >
<img <img
className={shared.cardIcon} className={shared.cardIcon}
src={getPerkIconUrl(perk.iconFilePath)} src={getPerkIconUrl(perk.iconFilePath)}
alt={perk.name} alt={perk.name}
loading="lazy" loading='lazy'
/> />
<span className={shared.cardName}>{perk.name}</span> <span className={shared.cardName}>{perk.name}</span>
<span className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}> <span
{killer ? 'Killer' : 'Survivor'} className={`${shared.rolePip} ${killer ? shared.rolePipKiller : ''}`}
</span> >
</div> {killer ? 'Killer' : 'Survivor'}
); </span>
})} </div>
</div> );
</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 = { export type Perk = {
id: string; id: string;
name: string; name: string;
iconFilePath: string; iconFilePath: string;
role: number; role: number;
}; };
export const getPerkIconUrl = (iconFilePath: string) => { export const getPerkIconUrl = (iconFilePath: string) => {
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0]; const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
return `${DB_BASE_URL}/icons/perk-icons/${file}.png`; return `${DB_BASE_URL}/icons/perk-icons/${file}.png`;
}; };
+48 -34
View File
@@ -1,44 +1,58 @@
"use client"; 'use client';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { useWebsocketStore } from "../store/useWebsocketStore"; import { useWebsocketStore } from '../store/useWebsocketStore';
import styles from "../styles/AppContainer.module.css"; import styles from '../styles/AppContainer.module.css';
export default function AppContainer({ children }: { children: React.ReactNode }) { export default function AppContainer({
const { connect, isConnected } = useWebsocketStore(); children
const [showHelp, setShowHelp] = useState(false); }: {
children: React.ReactNode;
}) {
const { connect, isConnected } = useWebsocketStore();
const [showHelp, setShowHelp] = useState(false);
useEffect(() => { useEffect(() => {
connect(); connect();
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowHelp(true); setShowHelp(true);
}, 3000); }, 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [connect]); }, [connect]);
if (!isConnected) { if (!isConnected) {
return ( return (
<div className={styles.overlay}> <div className={styles.overlay}>
<div className={styles.card}> <div className={styles.card}>
<h1 className={styles.title}>Hex: Unlocked</h1> <h1 className={styles.title}>Hex: Unlocked</h1>
<p className={styles.subtitle}>Awaiting unlocker connection</p> <p className={styles.subtitle}>Awaiting unlocker connection</p>
<div className={styles.spinner}></div> <div className={styles.spinner}></div>
{showHelp && ( {showHelp && (
<div className={styles.helpText}> <div className={styles.helpText}>
<p style={{ marginBottom: '0.5rem' }}>Make sure the client is running.</p> <p style={{ marginBottom: '0.5rem' }}>
<p> Make sure the client is running.
Don't have it? Download it <a href="https://git.neru.rip/neru/HexUnlocked" target="_blank" rel="noreferrer" className={styles.link}>here</a> </p>
</p> <p>
</div> Don't have it? Download it{' '}
)} <a
</div> href='https://git.neru.rip/neru/HexUnlocked'
</div> target='_blank'
); rel='noreferrer'
} className={styles.link}
>
here
</a>
</p>
</div>
)}
</div>
</div>
);
}
return <>{children}</>; return <>{children}</>;
} }
+76 -51
View File
@@ -4,62 +4,87 @@ import { randInRange } from '@/lib/utils';
import styles from '../styles/QuantityCard.module.css'; import styles from '../styles/QuantityCard.module.css';
type Props = { type Props = {
id: string; id: string;
name: string; name: string;
iconUrl: string; iconUrl: string;
qty: number; qty: number;
randMin: number; randMin: number;
randMax: number; randMax: number;
onSetQty: (id: string, qty: number) => void; onSetQty: (id: string, qty: number) => void;
}; };
const clamp = (v: number) => Math.min(32767, Math.max(0, v)); const clamp = (v: number) => Math.min(32767, Math.max(0, v));
export default function QuantityCard({ id, name, iconUrl, qty, randMin, randMax, onSetQty }: Props) { export default function QuantityCard({
const active = qty > 0; id,
name,
iconUrl,
qty,
randMin,
randMax,
onSetQty
}: Props) {
const active = qty > 0;
return ( return (
<div className={`${styles.card} ${active ? styles.cardActive : ''}`}> <div className={`${styles.card} ${active ? styles.cardActive : ''}`}>
<img className={styles.icon} src={iconUrl} alt={name} loading="lazy" /> <img className={styles.icon} src={iconUrl} alt={name} loading='lazy' />
<span className={styles.name}>{name}</span> <span className={styles.name}>{name}</span>
{active ? ( {active ? (
<> <>
<div className={styles.qtyRow}> <div className={styles.qtyRow}>
<button className={styles.qtyBtn} onClick={() => onSetQty(id, clamp(qty - 1))}></button> <button
<input className={styles.qtyBtn}
className={styles.qtyInput} onClick={() => onSetQty(id, clamp(qty - 1))}
type="number" >
value={qty}
min={0} </button>
max={5000} <input
onChange={e => { className={styles.qtyInput}
const v = parseInt(e.target.value); type='number'
if (!isNaN(v)) onSetQty(id, clamp(v)); value={qty}
}} min={0}
/> max={5000}
<button className={styles.qtyBtn} onClick={() => onSetQty(id, clamp(qty + 1))}>+</button> onChange={(e) => {
</div> const v = parseInt(e.target.value);
if (!isNaN(v)) onSetQty(id, clamp(v));
}}
/>
<button
className={styles.qtyBtn}
onClick={() => onSetQty(id, clamp(qty + 1))}
>
+
</button>
</div>
<div className={styles.quickRow}> <div className={styles.quickRow}>
<button className={styles.quickBtn} onClick={() => onSetQty(id, 100)}>100</button> <button
<button className={styles.quickBtn}
className={`${styles.quickBtn} ${styles.quickBtnRand}`} onClick={() => onSetQty(id, 100)}
onClick={() => onSetQty(id, randInRange(randMin, randMax))} >
> 100
rand </button>
</button> <button
<button className={`${styles.quickBtn} ${styles.quickBtnRand}`}
className={`${styles.quickBtn} ${styles.quickBtnRemove}`} onClick={() => onSetQty(id, randInRange(randMin, randMax))}
onClick={() => onSetQty(id, 0)} >
> rand
</button>
</button> <button
</div> className={`${styles.quickBtn} ${styles.quickBtnRemove}`}
</> onClick={() => onSetQty(id, 0)}
) : ( >
<button className={styles.addBtn} onClick={() => onSetQty(id, 100)}>+ Add</button>
)} </button>
</div> </div>
); </>
) : (
<button className={styles.addBtn} onClick={() => onSetQty(id, 100)}>
+ Add
</button>
)}
</div>
);
} }
+28 -18
View File
@@ -1,24 +1,34 @@
import Link from "next/link"; import Link from 'next/link';
import styles from '../styles/Sidebar.module.css'; import styles from '../styles/Sidebar.module.css';
export default function Sidebar() { export default function Sidebar() {
return ( return (
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<h1 className={styles.title}>Hex: Unlocked</h1> <h1 className={styles.title}>Hex: Unlocked</h1>
<nav> <nav>
<Link href="/" className={styles.navLink}>Dashboard</Link> <Link href='/' className={styles.navLink}>
<Link href="/characters" className={styles.navLink}>Characters</Link> Dashboard
<Link href="/customizations" className={styles.navLink}>Customizations</Link> </Link>
<Link href="/items" className={styles.navLink}>Items, offerings & addons</Link> <Link href='/characters' className={styles.navLink}>
<Link href="/perks" className={styles.navLink}>Perks</Link> Characters
<Link href="/dlcs" className={styles.navLink}>DLCs</Link> </Link>
</nav> <Link href='/customizations' className={styles.navLink}>
Customizations
</Link>
<Link href='/items' className={styles.navLink}>
Items, offerings & addons
</Link>
<Link href='/perks' className={styles.navLink}>
Perks
</Link>
<Link href='/dlcs' className={styles.navLink}>
DLCs
</Link>
</nav>
<button className={styles.syncButton}> <button className={styles.syncButton}>Synchronize</button>
Synchronize </aside>
</button> );
</aside> }
);
};
+26 -24
View File
@@ -1,30 +1,32 @@
export const DB_BASE_URL = process.env.NODE_ENV === 'development' ? '' : 'https://dbd-db.neru.rip'; export const DB_BASE_URL =
process.env.NODE_ENV === 'development' ? '' : 'https://dbd-db.neru.rip';
const _cache = new Map<string, Promise<any>>(); const _cache = new Map<string, Promise<any>>();
export function fetchDB<T = any>(path: string): Promise<T> { export function fetchDB<T = any>(path: string): Promise<T> {
if (!_cache.has(path)) { if (!_cache.has(path)) {
_cache.set( _cache.set(
path, path,
fetch(`${DB_BASE_URL}${path}`) fetch(`${DB_BASE_URL}${path}`)
.then(r => { .then((r) => {
if (!r.ok) throw new Error(`[db] ${r.status} ${path}`); if (!r.ok) throw new Error(`[db] ${r.status} ${path}`);
return r.json(); return r.json();
}) })
.catch(err => { .catch((err) => {
_cache.delete(path); _cache.delete(path);
console.error(err); console.error(err);
return []; return [];
}) })
); );
} }
return _cache.get(path)!; return _cache.get(path)!;
} }
export const fetchCharacters = () => fetchDB('/data/characters.json'); export const fetchCharacters = () => fetchDB('/data/characters.json');
export const fetchCustomizations = () => fetchDB('/data/customization_items.json'); export const fetchCustomizations = () =>
export const fetchItems = () => fetchDB('/data/items.json'); fetchDB('/data/customization_items.json');
export const fetchOfferings = () => fetchDB('/data/offerings.json'); export const fetchItems = () => fetchDB('/data/items.json');
export const fetchDLCs = () => fetchDB('/data/dlcs.json'); export const fetchOfferings = () => fetchDB('/data/offerings.json');
export const fetchAddons = () => fetchDB('/data/addons.json'); export const fetchDLCs = () => fetchDB('/data/dlcs.json');
export const fetchPerks = () => fetchDB('/data/perks.json'); export const fetchAddons = () => fetchDB('/data/addons.json');
export const fetchPerks = () => fetchDB('/data/perks.json');
+2 -1
View File
@@ -18,7 +18,8 @@ export const cleanFolderName = (name: string): string =>
export const isKiller = (idx: number): boolean => idx >= 268435456; export const isKiller = (idx: number): boolean => idx >= 268435456;
export const isNamedDLC = (dlc: DLC): dlc is DLC & { name: string } => !!dlc.name && !dlc.name.startsWith('@'); export const isNamedDLC = (dlc: DLC): dlc is DLC & { name: string } =>
!!dlc.name && !dlc.name.startsWith('@');
export const randInRange = (min: number, max: number) => { export const randInRange = (min: number, max: number) => {
const lo = Math.min(min, max); const lo = Math.min(min, max);
+243 -140
View File
@@ -2,161 +2,264 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
export interface InventoryState { export interface InventoryState {
unlockedCharacters: string[]; unlockedCharacters: string[];
unlockedCustomizations: string[]; unlockedCustomizations: string[];
unlockedDLCs: string[]; unlockedDLCs: string[];
unlockedPerks: string[]; unlockedPerks: string[];
items: Record<string, number>; items: Record<string, number>;
offerings: Record<string, number>; offerings: Record<string, number>;
addons: Record<string, number>; addons: Record<string, number>;
spoofItems: boolean; spoofItems: boolean;
spoofPerks: boolean; spoofPerks: boolean;
spoofCatalog: boolean; spoofCatalog: boolean;
spoofDLCs: boolean; spoofDLCs: boolean;
toggleItem: (id: string, category: 'characters' | 'customizations' | 'items' | 'offerings' | 'addons' | 'dlcs' | 'perks') => void; toggleItem: (
setItemQuantity: (id: string, qty: number) => void; id: string,
setOfferingQuantity: (id: string, qty: number) => void; category:
setAddonQuantity: (id: string, qty: number) => void; | 'characters'
setToggle: (key: 'spoofItems' | 'spoofPerks' | 'spoofCatalog' | 'spoofDLCs', val: boolean) => void; | 'customizations'
importToggles: (toggles: any) => void; | 'items'
| 'offerings'
| 'addons'
| 'dlcs'
| 'perks'
) => void;
setItemQuantity: (id: string, qty: number) => void;
setOfferingQuantity: (id: string, qty: number) => void;
setAddonQuantity: (id: string, qty: number) => void;
setToggle: (
key: 'spoofItems' | 'spoofPerks' | 'spoofCatalog' | 'spoofDLCs',
val: boolean
) => void;
importToggles: (toggles: any) => void;
unlockAllInCategory: (category: 'characters' | 'customizations' | 'items' | 'offerings' | 'addons' | 'dlcs' | 'perks', allIds: string[], qty?: number) => void; unlockAllInCategory: (
clearCategory: (category: 'characters' | 'customizations' | 'items' | 'offerings' | 'addons' | 'dlcs' | 'perks') => void; category:
clearAll: () => void; | 'characters'
importProfile: (profile: any) => void; | 'customizations'
| 'items'
| 'offerings'
| 'addons'
| 'dlcs'
| 'perks',
allIds: string[],
qty?: number
) => void;
clearCategory: (
category:
| 'characters'
| 'customizations'
| 'items'
| 'offerings'
| 'addons'
| 'dlcs'
| 'perks'
) => void;
clearAll: () => void;
importProfile: (profile: any) => void;
} }
export const useInventoryStore = create<InventoryState>()( export const useInventoryStore = create<InventoryState>()(
persist( persist(
(set) => ({ (set) => ({
unlockedCharacters: [], unlockedCharacters: [],
unlockedCustomizations: [], unlockedCustomizations: [],
unlockedDLCs: [], unlockedDLCs: [],
unlockedPerks: [], unlockedPerks: [],
items: {}, items: {},
offerings: {}, offerings: {},
addons: {}, addons: {},
spoofItems: false, spoofItems: false,
spoofPerks: false, spoofPerks: false,
spoofCatalog: false, spoofCatalog: false,
spoofDLCs: false, spoofDLCs: false,
setToggle: (key, val) => set({ [key]: val }), setToggle: (key, val) => set({ [key]: val }),
importToggles: (toggles) => set((state) => { importToggles: (toggles) =>
if (!toggles) return {}; set((state) => {
return { if (!toggles) return {};
spoofItems: typeof toggles.spoofItems === 'boolean' ? toggles.spoofItems : state.spoofItems, return {
spoofPerks: typeof toggles.spoofPerks === 'boolean' ? toggles.spoofPerks : state.spoofPerks, spoofItems:
spoofCatalog: typeof toggles.spoofCatalog === 'boolean' ? toggles.spoofCatalog : state.spoofCatalog, typeof toggles.spoofItems === 'boolean'
spoofDLCs: typeof toggles.spoofDLCs === 'boolean' ? toggles.spoofDLCs : state.spoofDLCs, ? toggles.spoofItems
}; : state.spoofItems,
}), spoofPerks:
typeof toggles.spoofPerks === 'boolean'
? toggles.spoofPerks
: state.spoofPerks,
spoofCatalog:
typeof toggles.spoofCatalog === 'boolean'
? toggles.spoofCatalog
: state.spoofCatalog,
spoofDLCs:
typeof toggles.spoofDLCs === 'boolean'
? toggles.spoofDLCs
: state.spoofDLCs
};
}),
toggleItem: (id, category) => set((state) => { toggleItem: (id, category) =>
if (category === 'characters') { set((state) => {
const arr = state.unlockedCharacters; if (category === 'characters') {
return { unlockedCharacters: arr.includes(id) ? arr.filter(i => i !== id) : [...arr, id] }; const arr = state.unlockedCharacters;
} else if (category === 'customizations') { return {
const arr = state.unlockedCustomizations; unlockedCharacters: arr.includes(id)
return { unlockedCustomizations: arr.includes(id) ? arr.filter(i => i !== id) : [...arr, id] }; ? arr.filter((i) => i !== id)
} else if (category === 'dlcs') { : [...arr, id]
const arr = state.unlockedDLCs; };
return { unlockedDLCs: arr.includes(id) ? arr.filter(i => i !== id) : [...arr, id] }; } else if (category === 'customizations') {
} else if (category === 'perks') { const arr = state.unlockedCustomizations;
const arr = state.unlockedPerks; return {
return { unlockedPerks: arr.includes(id) ? arr.filter(i => i !== id) : [...arr, id] }; unlockedCustomizations: arr.includes(id)
} else if (category === 'items') { ? arr.filter((i) => i !== id)
const newItems = { ...state.items }; : [...arr, id]
if ((newItems[id] || 0) > 0) delete newItems[id]; else newItems[id] = 100; };
return { items: newItems }; } else if (category === 'dlcs') {
} else if (category === 'addons') { const arr = state.unlockedDLCs;
const newAddons = { ...state.addons }; return {
if ((newAddons[id] || 0) > 0) delete newAddons[id]; else newAddons[id] = 100; unlockedDLCs: arr.includes(id)
return { addons: newAddons }; ? arr.filter((i) => i !== id)
} else { : [...arr, id]
const newOfferings = { ...state.offerings }; };
if ((newOfferings[id] || 0) > 0) delete newOfferings[id]; else newOfferings[id] = 100; } else if (category === 'perks') {
return { offerings: newOfferings }; const arr = state.unlockedPerks;
} return {
}), unlockedPerks: arr.includes(id)
? arr.filter((i) => i !== id)
: [...arr, id]
};
} else if (category === 'items') {
const newItems = { ...state.items };
if ((newItems[id] || 0) > 0) delete newItems[id];
else newItems[id] = 100;
return { items: newItems };
} else if (category === 'addons') {
const newAddons = { ...state.addons };
if ((newAddons[id] || 0) > 0) delete newAddons[id];
else newAddons[id] = 100;
return { addons: newAddons };
} else {
const newOfferings = { ...state.offerings };
if ((newOfferings[id] || 0) > 0) delete newOfferings[id];
else newOfferings[id] = 100;
return { offerings: newOfferings };
}
}),
setItemQuantity: (id, qty) => set((state) => { setItemQuantity: (id, qty) =>
const newItems = { ...state.items }; set((state) => {
if (qty <= 0) delete newItems[id]; else newItems[id] = Math.min(32767, Math.max(0, qty)); const newItems = { ...state.items };
return { items: newItems }; if (qty <= 0) delete newItems[id];
}), else newItems[id] = Math.min(32767, Math.max(0, qty));
return { items: newItems };
}),
setOfferingQuantity: (id, qty) => set((state) => { setOfferingQuantity: (id, qty) =>
const newOfferings = { ...state.offerings }; set((state) => {
if (qty <= 0) delete newOfferings[id]; else newOfferings[id] = Math.min(32767, Math.max(0, qty)); const newOfferings = { ...state.offerings };
return { offerings: newOfferings }; if (qty <= 0) delete newOfferings[id];
}), else newOfferings[id] = Math.min(32767, Math.max(0, qty));
return { offerings: newOfferings };
}),
setAddonQuantity: (id, qty) => set((state) => { setAddonQuantity: (id, qty) =>
const newAddons = { ...state.addons }; set((state) => {
if (qty <= 0) delete newAddons[id]; else newAddons[id] = Math.min(32767, Math.max(0, qty)); const newAddons = { ...state.addons };
return { addons: newAddons }; if (qty <= 0) delete newAddons[id];
}), else newAddons[id] = Math.min(32767, Math.max(0, qty));
return { addons: newAddons };
}),
unlockAllInCategory: (category, allIds, qty = 100) => set((state) => { unlockAllInCategory: (category, allIds, qty = 100) =>
if (category === 'characters') return { unlockedCharacters: allIds }; set((state) => {
if (category === 'customizations') return { unlockedCustomizations: allIds }; if (category === 'characters') return { unlockedCharacters: allIds };
if (category === 'dlcs') return { unlockedDLCs: allIds }; if (category === 'customizations')
if (category === 'perks') return { unlockedPerks: allIds }; return { unlockedCustomizations: allIds };
if (category === 'items') { if (category === 'dlcs') return { unlockedDLCs: allIds };
const newItems = { ...state.items }; if (category === 'perks') return { unlockedPerks: allIds };
allIds.forEach(id => { newItems[id] = qty; }); if (category === 'items') {
return { items: newItems }; const newItems = { ...state.items };
} else if (category === 'addons') { allIds.forEach((id) => {
const newAddons = { ...state.addons }; newItems[id] = qty;
allIds.forEach(id => { newAddons[id] = qty; }); });
return { addons: newAddons }; return { items: newItems };
} else { } else if (category === 'addons') {
const newOfferings = { ...state.offerings }; const newAddons = { ...state.addons };
allIds.forEach(id => { newOfferings[id] = qty; }); allIds.forEach((id) => {
return { offerings: newOfferings }; newAddons[id] = qty;
} });
}), return { addons: newAddons };
} else {
const newOfferings = { ...state.offerings };
allIds.forEach((id) => {
newOfferings[id] = qty;
});
return { offerings: newOfferings };
}
}),
clearCategory: (category) => set(() => { clearCategory: (category) =>
if (category === 'characters') return { unlockedCharacters: [] }; set(() => {
if (category === 'customizations') return { unlockedCustomizations: [] }; if (category === 'characters') return { unlockedCharacters: [] };
if (category === 'dlcs') return { unlockedDLCs: [] }; if (category === 'customizations')
if (category === 'perks') return { unlockedPerks: [] }; return { unlockedCustomizations: [] };
if (category === 'items') return { items: {} }; if (category === 'dlcs') return { unlockedDLCs: [] };
if (category === 'addons') return { addons: {} }; if (category === 'perks') return { unlockedPerks: [] };
return { offerings: {} }; if (category === 'items') return { items: {} };
}), if (category === 'addons') return { addons: {} };
return { offerings: {} };
}),
clearAll: () => set({ clearAll: () =>
unlockedCharacters: [], set({
unlockedCustomizations: [], unlockedCharacters: [],
unlockedDLCs: [], unlockedCustomizations: [],
unlockedPerks: [], unlockedDLCs: [],
items: {}, unlockedPerks: [],
offerings: {}, items: {},
addons: {}, offerings: {},
}), addons: {}
}),
importProfile: (profile) => set((state) => { importProfile: (profile) =>
if (!profile) return {}; set((state) => {
return { if (!profile) return {};
unlockedCharacters: Array.isArray(profile.unlockedCharacters) ? profile.unlockedCharacters : state.unlockedCharacters, return {
unlockedCustomizations: Array.isArray(profile.unlockedCustomizations) ? profile.unlockedCustomizations : state.unlockedCustomizations, unlockedCharacters: Array.isArray(profile.unlockedCharacters)
unlockedDLCs: Array.isArray(profile.unlockedDLCs) ? profile.unlockedDLCs : state.unlockedDLCs, ? profile.unlockedCharacters
unlockedPerks: Array.isArray(profile.unlockedPerks) ? profile.unlockedPerks : state.unlockedPerks, : state.unlockedCharacters,
items: (profile.items && typeof profile.items === 'object') ? profile.items : state.items, unlockedCustomizations: Array.isArray(
offerings: (profile.offerings && typeof profile.offerings === 'object') ? profile.offerings : state.offerings, profile.unlockedCustomizations
addons: (profile.addons && typeof profile.addons === 'object') ? profile.addons : state.addons, )
}; ? profile.unlockedCustomizations
}) : state.unlockedCustomizations,
}), unlockedDLCs: Array.isArray(profile.unlockedDLCs)
{ ? profile.unlockedDLCs
name: 'hex-unlocked-inventory', : state.unlockedDLCs,
} unlockedPerks: Array.isArray(profile.unlockedPerks)
) ? profile.unlockedPerks
: state.unlockedPerks,
items:
profile.items && typeof profile.items === 'object'
? profile.items
: state.items,
offerings:
profile.offerings && typeof profile.offerings === 'object'
? profile.offerings
: state.offerings,
addons:
profile.addons && typeof profile.addons === 'object'
? profile.addons
: state.addons
};
})
}),
{
name: 'hex-unlocked-inventory'
}
)
); );
+101 -39
View File
@@ -1,56 +1,118 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { useInventoryStore } from './useInventoryStore'; import { useInventoryStore } from './useInventoryStore';
import { mapStoreToSpooferConfig, mapSpooferConfigToStore } from './wsProtocol';
interface WebsocketState { interface WebsocketState {
socket: WebSocket | null; socket: WebSocket | null;
isConnected: boolean; isConnected: boolean;
connect: () => void; connect: () => void;
} }
let lastTogglesJson = '';
let lastInventoryJson = '';
export const useWebsocketStore = create<WebsocketState>((set, get) => ({ export const useWebsocketStore = create<WebsocketState>((set, get) => ({
socket: null, socket: null,
isConnected: false, isConnected: false,
connect: () => { connect: () => {
if (get().socket) return; if (get().socket) return;
const ws = new WebSocket('ws://localhost:4444'); const ws = new WebSocket('ws://localhost:4444');
ws.onopen = () => { ws.onopen = () => {
console.log('Connected to Spoofer Engine'); console.log('Connected to Spoofer Engine');
set({ socket: ws, isConnected: true }); set({ socket: ws, isConnected: true });
}; };
ws.onclose = () => { ws.onclose = () => {
console.log('Disconnected. Retrying in 2s...'); console.log('Disconnected. Retrying in 2s...');
set({ socket: null, isConnected: false }); set({ socket: null, isConnected: false });
setTimeout(() => get().connect(), 2000); setTimeout(() => get().connect(), 2000);
}; };
ws.onerror = (err) => { ws.onerror = (err) => {
console.error('WS Error:', err); console.error('WS Error:', err);
ws.close(); ws.close();
}; };
ws.onmessage = (msg) => { ws.onmessage = async (msg) => {
try { try {
const payload = JSON.parse(msg.data); const payload = JSON.parse(msg.data);
if (payload.action === 'init_config')
useInventoryStore.getState().importProfile(payload.data); // C++ sends action=0 (INIT_CONFIG) on WebSocket open
} catch (e) { if (payload.action === 0) {
console.error("Failed to parse WS message", e); const mapped = await mapSpooferConfigToStore(payload.profile);
}
}; // Snapshot current state strings to prevent echo
} lastInventoryJson = JSON.stringify({
unlockedCharacters: mapped.unlockedCharacters,
unlockedCustomizations: mapped.unlockedCustomizations,
unlockedDLCs: mapped.unlockedDLCs,
unlockedPerks: mapped.unlockedPerks,
items: mapped.items,
offerings: mapped.offerings,
addons: mapped.addons
});
lastTogglesJson = JSON.stringify({
spoofItems: payload.toggles?.spoofItems ?? false,
spoofPerks: payload.toggles?.spoofPerks ?? false,
spoofCatalog: payload.toggles?.spoofCatalog ?? false,
spoofDLCs: payload.toggles?.spoofDLCs ?? false
});
useInventoryStore.getState().importProfile(mapped);
useInventoryStore.getState().importToggles(payload.toggles);
}
} catch (e) {
console.error('Failed to parse WS message', e);
}
};
}
})); }));
useInventoryStore.subscribe((newState) => { // Subscribe to store changes and sync to C++ client
const { socket, isConnected } = useWebsocketStore.getState(); useInventoryStore.subscribe((state) => {
const { socket, isConnected } = useWebsocketStore.getState();
if (!isConnected || !socket || socket.readyState !== WebSocket.OPEN) return;
if (isConnected && socket) { // --- Check toggles ---
socket.send(JSON.stringify({ const toggles = {
action: "sync_inventory", spoofItems: state.spoofItems,
data: newState spoofPerks: state.spoofPerks,
})); spoofCatalog: state.spoofCatalog,
} spoofDLCs: state.spoofDLCs
};
const togglesJson = JSON.stringify(toggles);
if (togglesJson !== lastTogglesJson) {
lastTogglesJson = togglesJson;
socket.send(JSON.stringify({ action: 2, toggles }));
}
// --- Check inventory ---
const inventory = {
unlockedCharacters: state.unlockedCharacters,
unlockedCustomizations: state.unlockedCustomizations,
unlockedDLCs: state.unlockedDLCs,
unlockedPerks: state.unlockedPerks,
items: state.items,
offerings: state.offerings,
addons: state.addons
};
const inventoryJson = JSON.stringify(inventory);
if (inventoryJson !== lastInventoryJson) {
lastInventoryJson = inventoryJson;
mapStoreToSpooferConfig(state).then((profile) => {
// Re-check connection in case it dropped during async mapping
const ws = useWebsocketStore.getState();
if (
ws.isConnected &&
ws.socket &&
ws.socket.readyState === WebSocket.OPEN
) {
ws.socket.send(JSON.stringify({ action: 1, profile }));
}
});
}
}); });
+155 -131
View File
@@ -1,165 +1,189 @@
import { fetchDLCs, fetchItems, fetchOfferings, fetchAddons, fetchPerks } from '../lib/db'; import {
fetchDLCs,
fetchItems,
fetchOfferings,
fetchAddons,
fetchPerks
} from '../lib/db';
import { DLC } from '../lib/utils'; import { DLC } from '../lib/utils';
export interface SpooferConfig { export interface SpooferConfig {
camperItems: Record<string, number>; camperItems: Record<string, number>;
camperAddons: Record<string, number>; camperAddons: Record<string, number>;
slasherAddons: Record<string, number>; slasherAddons: Record<string, number>;
camperOfferings: Record<string, number>; camperOfferings: Record<string, number>;
slasherOfferings: Record<string, number>; slasherOfferings: Record<string, number>;
globalOfferings: Record<string, number>; globalOfferings: Record<string, number>;
camperPerks: string[]; camperPerks: string[];
slasherPerks: string[]; slasherPerks: string[];
catalogItemIds: string[]; catalogItemIds: string[];
dlcListGRDK: string[]; dlcListGRDK: string[];
dlcListEGS: string[]; dlcListEGS: string[];
dlcListSteam: string[]; dlcListSteam: string[];
} }
export interface Toggles { export interface Toggles {
spoofItems: boolean; spoofItems: boolean;
spoofPerks: boolean; spoofPerks: boolean;
spoofCatalog: boolean; spoofCatalog: boolean;
spoofDLCs: boolean; spoofDLCs: boolean;
} }
export async function mapStoreToSpooferConfig(state: { export async function mapStoreToSpooferConfig(state: {
unlockedCharacters: string[]; unlockedCharacters: string[];
unlockedCustomizations: string[]; unlockedCustomizations: string[];
unlockedDLCs: string[]; unlockedDLCs: string[];
unlockedPerks: string[]; unlockedPerks: string[];
items: Record<string, number>; items: Record<string, number>;
offerings: Record<string, number>; offerings: Record<string, number>;
addons: Record<string, number>; addons: Record<string, number>;
}): Promise<SpooferConfig> { }): Promise<SpooferConfig> {
const [dlcs, allItems, allOfferings, allAddons, allPerks] = await Promise.all([ const [dlcs, allItems, allOfferings, allAddons, allPerks] = await Promise.all(
fetchDLCs(), [fetchDLCs(), fetchItems(), fetchOfferings(), fetchAddons(), fetchPerks()]
fetchItems(), );
fetchOfferings(),
fetchAddons(),
fetchPerks()
]);
const dlcMap = new Map<string, DLC>(); const dlcMap = new Map<string, DLC>();
dlcs.forEach((d: DLC) => dlcMap.set(d.id, d)); dlcs.forEach((d: DLC) => dlcMap.set(d.id, d));
const itemIdSet = new Set<string>(); const itemIdSet = new Set<string>();
allItems.forEach((i: { id: string }) => itemIdSet.add(i.id)); allItems.forEach((i: { id: string }) => itemIdSet.add(i.id));
const offeringMap = new Map<string, { role: number }>(); const offeringMap = new Map<string, { role: number }>();
allOfferings.forEach((o: { id: string; role: number }) => offeringMap.set(o.id, o)); allOfferings.forEach((o: { id: string; role: number }) =>
offeringMap.set(o.id, o)
);
const addonMap = new Map<string, { role: number }>(); const addonMap = new Map<string, { role: number }>();
allAddons.forEach((a: { id: string; role: number }) => addonMap.set(a.id, a)); allAddons.forEach((a: { id: string; role: number }) => addonMap.set(a.id, a));
const perkMap = new Map<string, { role: number }>(); const perkMap = new Map<string, { role: number }>();
allPerks.forEach((p: { id: string; role: number }) => perkMap.set(p.id, p)); allPerks.forEach((p: { id: string; role: number }) => perkMap.set(p.id, p));
const dlcListSteam: string[] = []; // --- DLCs ---
const dlcListEGS: string[] = []; const dlcListSteam: string[] = [];
const dlcListGRDK: string[] = []; const dlcListEGS: string[] = [];
const dlcListGRDK: string[] = [];
for (const id of state.unlockedDLCs) { for (const id of state.unlockedDLCs) {
const dlc = dlcMap.get(id); const dlc = dlcMap.get(id);
if (!dlc?.dlcIds) continue; if (!dlc?.dlcIds) continue;
if (dlc.dlcIds.steam && dlc.dlcIds.steam !== '0' && dlc.dlcIds.steam !== '-1') if (
dlcListSteam.push(dlc.dlcIds.steam); dlc.dlcIds.steam &&
if (dlc.dlcIds.epic && dlc.dlcIds.epic !== 'FFFFFFFFFFFFFFFF' && dlc.dlcIds.epic !== '0') dlc.dlcIds.steam !== '0' &&
dlcListEGS.push(dlc.dlcIds.epic); dlc.dlcIds.steam !== '-1'
if (dlc.dlcIds.grdk && dlc.dlcIds.grdk !== '9ZZZZZZZZZZZ' && dlc.dlcIds.grdk !== '0') )
dlcListGRDK.push(dlc.dlcIds.grdk); dlcListSteam.push(dlc.dlcIds.steam);
} if (
dlc.dlcIds.epic &&
dlc.dlcIds.epic !== 'FFFFFFFFFFFFFFFF' &&
dlc.dlcIds.epic !== '0'
)
dlcListEGS.push(dlc.dlcIds.epic);
if (
dlc.dlcIds.grdk &&
dlc.dlcIds.grdk !== '9ZZZZZZZZZZZ' &&
dlc.dlcIds.grdk !== '0'
)
dlcListGRDK.push(dlc.dlcIds.grdk);
}
const camperItems: Record<string, number> = {}; // --- Items ---
for (const [id, qty] of Object.entries(state.items)) const camperItems: Record<string, number> = {};
if (qty > 0 && itemIdSet.has(id)) camperItems[id] = qty; for (const [id, qty] of Object.entries(state.items)) {
if (qty > 0 && itemIdSet.has(id)) camperItems[id] = qty;
}
// --- Offerings ---
const camperOfferings: Record<string, number> = {};
const slasherOfferings: Record<string, number> = {};
const globalOfferings: Record<string, number> = {};
const camperOfferings: Record<string, number> = {}; for (const [id, qty] of Object.entries(state.offerings)) {
const slasherOfferings: Record<string, number> = {}; if (qty <= 0) continue;
const globalOfferings: Record<string, number> = {}; const offering = offeringMap.get(id);
if (!offering) continue;
if (offering.role === 2) camperOfferings[id] = qty;
else if (offering.role === 1) slasherOfferings[id] = qty;
else globalOfferings[id] = qty;
}
for (const [id, qty] of Object.entries(state.offerings)) { // --- Addons ---
if (qty <= 0) continue; const camperAddons: Record<string, number> = {};
const offering = offeringMap.get(id); const slasherAddons: Record<string, number> = {};
if (!offering) continue;
if (offering.role === 2) camperOfferings[id] = qty;
else if (offering.role === 1) slasherOfferings[id] = qty;
else globalOfferings[id] = qty;
}
const camperAddons: Record<string, number> = {}; for (const [id, qty] of Object.entries(state.addons)) {
const slasherAddons: Record<string, number> = {}; if (qty <= 0) continue;
const addon = addonMap.get(id);
if (!addon) continue;
if (addon.role === 1) slasherAddons[id] = qty;
else camperAddons[id] = qty;
}
for (const [id, qty] of Object.entries(state.addons)) { // --- Perks ---
if (qty <= 0) continue; const camperPerks: string[] = [];
const addon = addonMap.get(id); const slasherPerks: string[] = [];
if (!addon) continue;
if (addon.role === 1) slasherAddons[id] = qty;
else camperAddons[id] = qty;
}
const camperPerks: string[] = []; for (const id of state.unlockedPerks) {
const slasherPerks: string[] = []; const perk = perkMap.get(id);
if (!perk) continue;
if (perk.role === 1) slasherPerks.push(id);
else camperPerks.push(id);
}
for (const id of state.unlockedPerks) { return {
const perk = perkMap.get(id); camperItems,
if (!perk) continue; camperAddons,
if (perk.role === 1) slasherPerks.push(id); slasherAddons,
else camperPerks.push(id); camperOfferings,
} slasherOfferings,
globalOfferings,
return { camperPerks,
camperItems, slasherPerks,
camperAddons, catalogItemIds: [...state.unlockedCustomizations],
slasherAddons, dlcListSteam,
camperOfferings, dlcListEGS,
slasherOfferings, dlcListGRDK
globalOfferings, };
camperPerks,
slasherPerks,
catalogItemIds: [...state.unlockedCustomizations],
dlcListSteam,
dlcListEGS,
dlcListGRDK,
};
} }
export async function mapSpooferConfigToStore(profile: SpooferConfig) { export async function mapSpooferConfigToStore(profile: SpooferConfig) {
const dlcs = await fetchDLCs(); const dlcs = await fetchDLCs();
const unlockedDLCs: string[] = []; const unlockedDLCs: string[] = [];
for (const dlc of dlcs as DLC[]) { for (const dlc of dlcs as DLC[]) {
if (!dlc.dlcIds) continue; if (!dlc.dlcIds) continue;
const hasSteam = dlc.dlcIds.steam && profile.dlcListSteam?.includes(dlc.dlcIds.steam); const hasSteam =
const hasEpic = dlc.dlcIds.epic && profile.dlcListEGS?.includes(dlc.dlcIds.epic); dlc.dlcIds.steam && profile.dlcListSteam?.includes(dlc.dlcIds.steam);
const hasGrdk = dlc.dlcIds.grdk && profile.dlcListGRDK?.includes(dlc.dlcIds.grdk); const hasEpic =
if (hasSteam || hasEpic || hasGrdk) unlockedDLCs.push(dlc.id); dlc.dlcIds.epic && profile.dlcListEGS?.includes(dlc.dlcIds.epic);
} const hasGrdk =
dlc.dlcIds.grdk && profile.dlcListGRDK?.includes(dlc.dlcIds.grdk);
if (hasSteam || hasEpic || hasGrdk) unlockedDLCs.push(dlc.id);
}
const offerings: Record<string, number> = { const offerings: Record<string, number> = {
...(profile.globalOfferings || {}), ...(profile.globalOfferings || {}),
...(profile.camperOfferings || {}), ...(profile.camperOfferings || {}),
...(profile.slasherOfferings || {}), ...(profile.slasherOfferings || {})
}; };
const addons: Record<string, number> = { const addons: Record<string, number> = {
...(profile.camperAddons || {}), ...(profile.camperAddons || {}),
...(profile.slasherAddons || {}), ...(profile.slasherAddons || {})
}; };
const unlockedPerks: string[] = [ const unlockedPerks: string[] = [
...(profile.camperPerks || []), ...(profile.camperPerks || []),
...(profile.slasherPerks || []), ...(profile.slasherPerks || [])
]; ];
return { return {
unlockedCharacters: [] as string[], unlockedCharacters: [] as string[],
unlockedCustomizations: profile.catalogItemIds || [], unlockedCustomizations: profile.catalogItemIds || [],
unlockedDLCs, unlockedDLCs,
unlockedPerks, unlockedPerks,
items: profile.camperItems || {}, items: profile.camperItems || {},
offerings, offerings,
addons, addons
}; };
} }
+62 -54
View File
@@ -1,83 +1,91 @@
.overlay { .overlay {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
background: #000000; background: #000000;
color: #ffffff; color: #ffffff;
font-family: 'Roboto Condensed', sans-serif; font-family: 'Roboto Condensed', sans-serif;
text-align: center; text-align: center;
} }
.card { .card {
background: #050505; background: #050505;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
border-top: 3px solid #a30000; border-top: 3px solid #a30000;
padding: 3rem 4rem; padding: 3rem 4rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.6); box-shadow: 0 0 20px rgba(0, 0, 0, 0.6);
min-width: 400px; min-width: 400px;
} }
.title { .title {
font-size: 2.25rem; font-size: 2.25rem;
text-transform: uppercase; text-transform: uppercase;
color: #ffffff; color: #ffffff;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8); text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8);
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
} }
.subtitle { .subtitle {
font-size: 0.9rem; font-size: 0.9rem;
color: #777777; color: #777777;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
} }
.spinner { .spinner {
width: 36px; width: 36px;
height: 36px; height: 36px;
border: 3px solid #1a1a1a; border: 3px solid #1a1a1a;
border-top: 3px solid #a30000; border-top: 3px solid #a30000;
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.helpText { .helpText {
color: #555555; color: #555555;
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 1rem; margin-top: 1rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
animation: fadeIn 1s ease-in; animation: fadeIn 1s ease-in;
} }
.link { .link {
color: #a30000; color: #a30000;
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.link:hover { .link:hover {
color: #ff0000; color: #ff0000;
text-shadow: 0 0 10px rgba(163, 0, 0, 0.4); text-shadow: 0 0 10px rgba(163, 0, 0, 0.4);
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
+7 -7
View File
@@ -1,9 +1,9 @@
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem; gap: 1rem;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
align-content: start; align-content: start;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
+116 -116
View File
@@ -2,207 +2,207 @@
tabs tabs
*/ */
.tabs { .tabs {
display: flex; display: flex;
gap: 0; gap: 0;
border-bottom: 1px solid #1a1a1a; border-bottom: 1px solid #1a1a1a;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
.tab { .tab {
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
margin-bottom: -1px; margin-bottom: -1px;
color: #444444; color: #444444;
padding: 0.7rem 1.4rem; padding: 0.7rem 1.4rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.tab:hover { .tab:hover {
color: #888888; color: #888888;
} }
.tabActive { .tabActive {
color: #ffffff; color: #ffffff;
border-bottom-color: #a30000; border-bottom-color: #a30000;
} }
/* /*
page btns page btns
*/ */
.pageActionBtn { .pageActionBtn {
background: transparent; background: transparent;
border: 1px solid #1e1e1e; border: 1px solid #1e1e1e;
color: #3a3a3a; color: #3a3a3a;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.pageActionBtn:hover { .pageActionBtn:hover {
color: #666666; color: #666666;
border-color: #2e2e2e; border-color: #2e2e2e;
} }
/* /*
char picker char picker
*/ */
.charGrid { .charGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.75rem; gap: 0.75rem;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
align-content: start; align-content: start;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.charCard { .charCard {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 0.75rem 0.5rem 0.6rem; padding: 0.75rem 0.5rem 0.6rem;
gap: 0.4rem; gap: 0.4rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
user-select: none; user-select: none;
} }
.charCard:hover { .charCard:hover {
border-color: #333333; border-color: #333333;
background: #111111; background: #111111;
} }
.charCardSelected { .charCardSelected {
border-color: #a30000; border-color: #a30000;
background: #0d0000; background: #0d0000;
} }
.charCardIcon { .charCardIcon {
width: 56px; width: 56px;
height: 56px; height: 56px;
object-fit: contain; object-fit: contain;
} }
.charCardName { .charCardName {
font-size: 0.62rem; font-size: 0.62rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-align: center; text-align: center;
color: #555555; color: #555555;
line-height: 1.3; line-height: 1.3;
} }
.charCardSelected .charCardName { .charCardSelected .charCardName {
color: #c9c9c9; color: #c9c9c9;
} }
.charCardFullyUnlocked { .charCardFullyUnlocked {
border-color: #6b4c00; border-color: #6b4c00;
background: #0d0900; background: #0d0900;
} }
.charCardFullyUnlocked:hover { .charCardFullyUnlocked:hover {
border-color: #b37a00; border-color: #b37a00;
box-shadow: 0 0 18px rgba(160, 110, 0, 0.3); box-shadow: 0 0 18px rgba(160, 110, 0, 0.3);
} }
.charCardFullyUnlocked .charCardName { .charCardFullyUnlocked .charCardName {
color: #9a7020; color: #9a7020;
} }
/* /*
sel view sel view
*/ */
.cosmeticsView { .cosmeticsView {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
} }
.cosmeticsHeader { .cosmeticsHeader {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
.backBtn { .backBtn {
background: transparent; background: transparent;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
color: #555555; color: #555555;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.backBtn:hover { .backBtn:hover {
color: #ffffff; color: #ffffff;
border-color: #333333; border-color: #333333;
} }
.cosmeticsCharName { .cosmeticsCharName {
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 1.25rem; font-size: 1.25rem;
color: #ffffff; color: #ffffff;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.cosmeticsBody { .cosmeticsBody {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.categoryGroup { .categoryGroup {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.categoryTitle { .categoryTitle {
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: #444444; color: #444444;
border-bottom: 1px solid #1a1a1a; border-bottom: 1px solid #1a1a1a;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* /*
grid grid
*/ */
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem; gap: 0.75rem;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
align-content: start; align-content: start;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.gridInline { .gridInline {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
+88 -88
View File
@@ -2,173 +2,173 @@
toggles toggles
*/ */
.overrideToggle { .overrideToggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
user-select: none; user-select: none;
} }
.overrideToggle:hover { .overrideToggle:hover {
border-color: #333333; border-color: #333333;
} }
.overrideToggleActive { .overrideToggleActive {
border-color: #4a0000; border-color: #4a0000;
background: #0d0000; background: #0d0000;
} }
.overrideToggleActive:hover { .overrideToggleActive:hover {
border-color: #a30000; border-color: #a30000;
box-shadow: 0 0 10px rgba(163, 0, 0, 0.2); box-shadow: 0 0 10px rgba(163, 0, 0, 0.2);
} }
.overrideIndicator { .overrideIndicator {
width: 10px; width: 10px;
height: 10px; height: 10px;
border: 1px solid #2a2a2a; border: 1px solid #2a2a2a;
background: transparent; background: transparent;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.overrideToggleActive .overrideIndicator { .overrideToggleActive .overrideIndicator {
background: #a30000; background: #a30000;
border-color: #ff0000; border-color: #ff0000;
box-shadow: 0 0 6px rgba(163, 0, 0, 0.6); box-shadow: 0 0 6px rgba(163, 0, 0, 0.6);
} }
.overrideLabel { .overrideLabel {
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #444444; color: #444444;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.overrideToggleActive .overrideLabel { .overrideToggleActive .overrideLabel {
color: #c9c9c9; color: #c9c9c9;
} }
.overrideWarning { .overrideWarning {
font-size: 0.65rem; font-size: 0.65rem;
color: #5a0000; color: #5a0000;
font-family: 'Roboto Condensed', sans-serif; font-family: 'Roboto Condensed', sans-serif;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
.overrideToggleActive .overrideWarning { .overrideToggleActive .overrideWarning {
color: #883333; color: #883333;
} }
/* /*
grid grid
*/ */
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.5rem; gap: 0.5rem;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
align-content: start; align-content: start;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
/* /*
cards cards
*/ */
.card { .card {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
border-left: 3px solid #1a1a1a; border-left: 3px solid #1a1a1a;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.85rem 1rem 0.85rem 1.1rem; padding: 0.85rem 1rem 0.85rem 1.1rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
user-select: none; user-select: none;
} }
.card:hover { .card:hover {
background: #111111; background: #111111;
border-left-color: #330000; border-left-color: #330000;
} }
.cardUnlocked { .cardUnlocked {
border-left-color: #a30000; border-left-color: #a30000;
background: #0d0000; background: #0d0000;
} }
.cardUnlocked:hover { .cardUnlocked:hover {
background: #100000; background: #100000;
border-left-color: #cc0000; border-left-color: #cc0000;
box-shadow: -3px 0 12px rgba(163, 0, 0, 0.15); box-shadow: -3px 0 12px rgba(163, 0, 0, 0.15);
} }
.cardName { .cardName {
flex: 1; flex: 1;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.85rem; font-size: 0.85rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: #444444; color: #444444;
line-height: 1.3; line-height: 1.3;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.cardUnlocked .cardName { .cardUnlocked .cardName {
color: #ffffff; color: #ffffff;
} }
.card:hover .cardName { .card:hover .cardName {
color: #666666; color: #666666;
} }
.cardUnlocked:hover .cardName { .cardUnlocked:hover .cardName {
color: #ffffff; color: #ffffff;
} }
/* /*
platform badges platform badges
*/ */
.platforms { .platforms {
display: flex; display: flex;
gap: 0.3rem; gap: 0.3rem;
flex-shrink: 0; flex-shrink: 0;
} }
.badge { .badge {
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.6rem; font-size: 0.6rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
border: 1px solid; border: 1px solid;
line-height: 1.4; line-height: 1.4;
} }
.badgeOff { .badgeOff {
color: #1e1e1e; color: #1e1e1e;
border-color: #181818; border-color: #181818;
} }
.badgeSteam { .badgeSteam {
color: #5a9cc4; color: #5a9cc4;
border-color: #2a4a66; border-color: #2a4a66;
} }
.badgeEpic { .badgeEpic {
color: #888888; color: #888888;
border-color: #3a3a3a; border-color: #3a3a3a;
} }
.badgeXbox { .badgeXbox {
color: #4a9e40; color: #4a9e40;
border-color: #225520; border-color: #225520;
} }
+151 -151
View File
@@ -1,252 +1,252 @@
.container { .container {
padding: 3rem 4rem; padding: 3rem 4rem;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.header { .header {
margin-bottom: 3rem; margin-bottom: 3rem;
border-bottom: 2px solid #1a1a1a; border-bottom: 2px solid #1a1a1a;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
} }
.title { .title {
font-size: 2.25rem; font-size: 2.25rem;
text-transform: uppercase; text-transform: uppercase;
color: #ffffff; color: #ffffff;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8); text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8);
} }
.subtitle { .subtitle {
font-size: 0.9rem; font-size: 0.9rem;
color: #777777; color: #777777;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin: 0; margin: 0;
} }
.statsGrid { .statsGrid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 1.5rem; gap: 1.5rem;
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@media (max-width: 1000px) { @media (max-width: 1000px) {
.statsGrid { .statsGrid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.statsGrid { .statsGrid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
.statCard { .statCard {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
border-top: 3px solid #330000; border-top: 3px solid #330000;
padding: 1.5rem; padding: 1.5rem;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.statCard:hover { .statCard:hover {
border-color: #a30000; border-color: #a30000;
box-shadow: 0 0 15px rgba(163, 0, 0, 0.15); box-shadow: 0 0 15px rgba(163, 0, 0, 0.15);
} }
.statLabel { .statLabel {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
color: #555555; color: #555555;
font-weight: bold; font-weight: bold;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.statValue { .statValue {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
line-height: 1; line-height: 1;
} }
.statTotal { .statTotal {
font-size: 0.9rem; font-size: 0.9rem;
color: #555555; color: #555555;
} }
.panelGrid { .panelGrid {
display: grid; display: grid;
grid-template-columns: 1fr 1.2fr; grid-template-columns: 1fr 1.2fr;
gap: 2rem; gap: 2rem;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.panelGrid { .panelGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.card { .card {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
border-top: 3px solid #630000; border-top: 3px solid #630000;
padding: 2rem; padding: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.cardTitle { .cardTitle {
font-size: 1.25rem; font-size: 1.25rem;
text-transform: uppercase; text-transform: uppercase;
color: #ffffff; color: #ffffff;
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
letter-spacing: 0.05em; letter-spacing: 0.05em;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
} }
.textarea { .textarea {
width: 100%; width: 100%;
height: 200px; height: 200px;
background: #050505; background: #050505;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
color: #999999; color: #999999;
font-family: monospace; font-family: monospace;
font-size: 0.8rem; font-size: 0.8rem;
padding: 1rem; padding: 1rem;
resize: none; resize: none;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.textarea:focus { .textarea:focus {
outline: none; outline: none;
border-color: #a30000; border-color: #a30000;
} }
.btnRow { .btnRow {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.primaryBtn { .primaryBtn {
background: #a30000; background: #a30000;
border: 1px solid #ff0000; border: 1px solid #ff0000;
color: #ffffff; color: #ffffff;
padding: 0.8rem 1.5rem; padding: 0.8rem 1.5rem;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.primaryBtn:hover { .primaryBtn:hover {
background: #ff0000; background: #ff0000;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.4); box-shadow: 0 0 10px rgba(255, 0, 0, 0.4);
} }
.secondaryBtn { .secondaryBtn {
background: #121212; background: #121212;
border: 1px solid #333333; border: 1px solid #333333;
color: #c9c9c9; color: #c9c9c9;
padding: 0.8rem 1.5rem; padding: 0.8rem 1.5rem;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.secondaryBtn:hover { .secondaryBtn:hover {
background: #1a1a1a; background: #1a1a1a;
color: #ffffff; color: #ffffff;
} }
.toggleRow { .toggleRow {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 0; padding: 1rem 0;
border-bottom: 1px solid #1a1a1a; border-bottom: 1px solid #1a1a1a;
} }
.toggleRow:last-child { .toggleRow:last-child {
border-bottom: none; border-bottom: none;
} }
.toggleInfo { .toggleInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.2rem;
padding-right: 1.5rem; padding-right: 1.5rem;
} }
.toggleLabel { .toggleLabel {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
color: #ffffff; color: #ffffff;
letter-spacing: 0.05em; letter-spacing: 0.05em;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
} }
.toggleDesc { .toggleDesc {
font-size: 0.68rem; font-size: 0.68rem;
color: #555555; color: #555555;
line-height: 1.3; line-height: 1.3;
} }
.switch { .switch {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 48px; width: 48px;
height: 24px; height: 24px;
flex-shrink: 0; flex-shrink: 0;
} }
.switch input { .switch input {
opacity: 0; opacity: 0;
width: 0; width: 0;
height: 0; height: 0;
} }
.slider { .slider {
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: #121212; background-color: #121212;
border: 1px solid #333333; border: 1px solid #333333;
transition: 0.2s; transition: 0.2s;
} }
.slider:before { .slider:before {
position: absolute; position: absolute;
content: ""; content: '';
height: 16px; height: 16px;
width: 16px; width: 16px;
left: 3px; left: 3px;
bottom: 3px; bottom: 3px;
background-color: #555555; background-color: #555555;
transition: 0.2s; transition: 0.2s;
} }
input:checked + .slider { input:checked + .slider {
background-color: #2b0000; background-color: #2b0000;
border-color: #a30000; border-color: #a30000;
} }
input:checked + .slider:before { input:checked + .slider:before {
transform: translateX(24px); transform: translateX(24px);
background-color: #a30000; background-color: #a30000;
box-shadow: 0 0 8px rgba(163, 0, 0, 0.7); box-shadow: 0 0 8px rgba(163, 0, 0, 0.7);
} }
+68 -66
View File
@@ -2,32 +2,34 @@
tabs tabs
*/ */
.tabs { .tabs {
display: flex; display: flex;
border-bottom: 1px solid #1a1a1a; border-bottom: 1px solid #1a1a1a;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
.tab { .tab {
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
margin-bottom: -1px; margin-bottom: -1px;
color: #444444; color: #444444;
padding: 0.7rem 1.4rem; padding: 0.7rem 1.4rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.tab:hover { color: #888888; } .tab:hover {
color: #888888;
}
.tabActive { .tabActive {
color: #ffffff; color: #ffffff;
border-bottom-color: #a30000; border-bottom-color: #a30000;
} }
/* /*
@@ -35,82 +37,82 @@
*/ */
.rangeGroup { .rangeGroup {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 0.85rem; padding: 0.5rem 0.85rem;
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
} }
.rangeLabel { .rangeLabel {
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: #444444; color: #444444;
white-space: nowrap; white-space: nowrap;
} }
.rangeInput { .rangeInput {
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid #2a2a2a;
color: #5599ff; color: #5599ff;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
width: 48px; width: 48px;
text-align: center; text-align: center;
padding: 0.1rem 0; padding: 0.1rem 0;
} }
.rangeInput::-webkit-outer-spin-button, .rangeInput::-webkit-outer-spin-button,
.rangeInput::-webkit-inner-spin-button { .rangeInput::-webkit-inner-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
.rangeInput:focus { .rangeInput:focus {
outline: none; outline: none;
border-bottom-color: #5599ff; border-bottom-color: #5599ff;
} }
.rangeSep { .rangeSep {
color: #333333; color: #333333;
font-size: 0.75rem; font-size: 0.75rem;
} }
/* /*
randomization btn randomization btn
*/ */
.randBtn { .randBtn {
background: transparent; background: transparent;
border: 1px solid #1a3a88; border: 1px solid #1a3a88;
color: #3366cc; color: #3366cc;
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.randBtn:hover { .randBtn:hover {
background: #0a1a44; background: #0a1a44;
color: #88aaff; color: #88aaff;
border-color: #4466cc; border-color: #4466cc;
box-shadow: 0 0 12px rgba(50, 80, 200, 0.3); box-shadow: 0 0 12px rgba(50, 80, 200, 0.3);
} }
/* /*
grid grid
*/ */
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem; gap: 0.75rem;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
align-content: start; align-content: start;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
+8 -8
View File
@@ -1,13 +1,13 @@
.layoutContainer { .layoutContainer {
display: flex; display: flex;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: #050505; background: #050505;
} }
.mainContent { .mainContent {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
background: #0a0a0a; background: #0a0a0a;
} }
+98 -96
View File
@@ -2,163 +2,165 @@
cards cards
*/ */
.card { .card {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 1rem 0.6rem 0.75rem; padding: 1rem 0.6rem 0.75rem;
gap: 0.5rem; gap: 0.5rem;
transition: border-color 0.15s ease, background 0.15s ease; transition:
user-select: none; border-color 0.15s ease,
background 0.15s ease;
user-select: none;
} }
.cardActive { .cardActive {
border-color: #a30000; border-color: #a30000;
background: #0d0000; background: #0d0000;
} }
.icon { .icon {
width: 52px; width: 52px;
height: 52px; height: 52px;
object-fit: contain; object-fit: contain;
filter: grayscale(1) brightness(0.3); filter: grayscale(1) brightness(0.3);
transition: filter 0.15s ease; transition: filter 0.15s ease;
pointer-events: none; pointer-events: none;
} }
.cardActive .icon { .cardActive .icon {
filter: none; filter: none;
} }
.name { .name {
font-size: 0.62rem; font-size: 0.62rem;
text-align: center; text-align: center;
color: #3a3a3a; color: #3a3a3a;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.3; line-height: 1.3;
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.cardActive .name { .cardActive .name {
color: #c9c9c9; color: #c9c9c9;
} }
/* /*
quantity rows quantity rows
*/ */
.qtyRow { .qtyRow {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
border: 1px solid #2a2a2a; border: 1px solid #2a2a2a;
background: #060606; background: #060606;
} }
.qtyBtn { .qtyBtn {
background: transparent; background: transparent;
border: none; border: none;
color: #555555; color: #555555;
width: 22px; width: 22px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
line-height: 1; line-height: 1;
transition: all 0.1s ease; transition: all 0.1s ease;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.qtyBtn:hover { .qtyBtn:hover {
color: #ffffff; color: #ffffff;
background: #1a0000; background: #1a0000;
} }
.qtyInput { .qtyInput {
flex: 1; flex: 1;
background: transparent; background: transparent;
border: none; border: none;
color: #a30000; color: #a30000;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.82rem; font-size: 0.82rem;
text-align: center; text-align: center;
width: 0; width: 0;
min-width: 0; min-width: 0;
padding: 0.3rem 0; padding: 0.3rem 0;
} }
.qtyInput::-webkit-outer-spin-button, .qtyInput::-webkit-outer-spin-button,
.qtyInput::-webkit-inner-spin-button { .qtyInput::-webkit-inner-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
.qtyInput:focus { .qtyInput:focus {
outline: none; outline: none;
} }
/* /*
quick actions quick actions
*/ */
.quickRow { .quickRow {
display: flex; display: flex;
gap: 0.3rem; gap: 0.3rem;
width: 100%; width: 100%;
} }
.quickBtn { .quickBtn {
flex: 1; flex: 1;
background: transparent; background: transparent;
border: 1px solid #1e1e1e; border: 1px solid #1e1e1e;
color: #3a3a3a; color: #3a3a3a;
padding: 0.25rem 0; padding: 0.25rem 0;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.65rem; font-size: 0.65rem;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: all 0.1s ease; transition: all 0.1s ease;
text-align: center; text-align: center;
} }
.quickBtn:hover { .quickBtn:hover {
color: #c9c9c9; color: #c9c9c9;
border-color: #333333; border-color: #333333;
} }
.quickBtnRand:hover { .quickBtnRand:hover {
color: #5599ff; color: #5599ff;
border-color: #1a3a88; border-color: #1a3a88;
background: #05060d; background: #05060d;
} }
.quickBtnRemove:hover { .quickBtnRemove:hover {
color: #ff4444; color: #ff4444;
border-color: #661111; border-color: #661111;
background: #0d0505; background: #0d0505;
} }
/* /*
buttons buttons
*/ */
.addBtn { .addBtn {
width: 100%; width: 100%;
background: transparent; background: transparent;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
color: #2e2e2e; color: #2e2e2e;
padding: 0.45rem; padding: 0.45rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
margin-top: auto; margin-top: auto;
} }
.addBtn:hover { .addBtn:hover {
color: #c9c9c9; color: #c9c9c9;
border-color: #4a0000; border-color: #4a0000;
background: #0d0000; background: #0d0000;
} }
+46 -46
View File
@@ -1,63 +1,63 @@
.sidebar { .sidebar {
width: 280px; width: 280px;
height: 100vh; height: 100vh;
background: #050505; background: #050505;
border-right: 2px solid #1a1a1a; border-right: 2px solid #1a1a1a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 2.5rem 1.5rem; padding: 2.5rem 1.5rem;
color: #c9c9c9; color: #c9c9c9;
font-family: 'Oswald', 'Roboto Condensed', sans-serif; font-family: 'Oswald', 'Roboto Condensed', sans-serif;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.title { .title {
font-size: 1.25rem; font-size: 1.25rem;
text-transform: uppercase; text-transform: uppercase;
color: #e4e4e4; color: #e4e4e4;
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid #330000; border-bottom: 2px solid #330000;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
} }
.navLink { .navLink {
display: block; display: block;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
text-decoration: none; text-decoration: none;
color: #777777; color: #777777;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.85rem; font-size: 0.85rem;
position: relative; position: relative;
transition: all 0.1s ease-in; transition: all 0.1s ease-in;
background: transparent; background: transparent;
border-left: 3px solid transparent; border-left: 3px solid transparent;
} }
.navLink:hover { .navLink:hover {
color: #ffffff; color: #ffffff;
background: linear-gradient(90deg, #1a0000, transparent); background: linear-gradient(90deg, #1a0000, transparent);
border-left: 3px solid #a30000; border-left: 3px solid #a30000;
} }
.syncButton { .syncButton {
margin-top: auto; margin-top: auto;
padding: 1rem; padding: 1rem;
background: #0a0a0a; background: #0a0a0a;
border: 1px solid #4a0000; border: 1px solid #4a0000;
color: #a30000; color: #a30000;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
.syncButton:hover { .syncButton:hover {
background: #a30000; background: #a30000;
color: #ffffff; color: #ffffff;
border-color: #ff0000; border-color: #ff0000;
box-shadow: 0 0 10px #7f0000; box-shadow: 0 0 10px #7f0000;
} }
+198 -199
View File
@@ -2,285 +2,284 @@
containers and stuff containers and stuff
*/ */
.container { .container {
padding: 3rem 4rem; padding: 3rem 4rem;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
/* /*
header header
*/ */
.header { .header {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
border-bottom: 2px solid #1a1a1a; border-bottom: 2px solid #1a1a1a;
flex-shrink: 0; flex-shrink: 0;
} }
.title { .title {
font-size: 2.25rem; font-size: 2.25rem;
text-transform: uppercase; text-transform: uppercase;
color: #ffffff; color: #ffffff;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8); text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8);
} }
.subtitle { .subtitle {
font-size: 0.8rem; font-size: 0.8rem;
color: #555555; color: #555555;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin: 0; margin: 0;
} }
/* /*
toolbar toolbar
*/ */
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.spacer { .spacer {
flex: 1; flex: 1;
} }
.searchInput { .searchInput {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
color: #c9c9c9; color: #c9c9c9;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-family: 'Roboto Condensed', sans-serif; font-family: 'Roboto Condensed', sans-serif;
font-size: 0.85rem; font-size: 0.85rem;
width: 240px; width: 240px;
transition: border-color 0.15s ease; transition: border-color 0.15s ease;
} }
.searchInput::placeholder { .searchInput::placeholder {
color: #444444; color: #444444;
} }
.searchInput:focus { .searchInput:focus {
outline: none; outline: none;
border-color: #a30000; border-color: #a30000;
} }
/* /*
role stuff role stuff
*/ */
.roleFilter { .roleFilter {
display: flex; display: flex;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
} }
.roleBtn { .roleBtn {
background: transparent; background: transparent;
border: none; border: none;
border-right: 1px solid #1a1a1a; border-right: 1px solid #1a1a1a;
color: #555555; color: #555555;
padding: 0.6rem 1.1rem; padding: 0.6rem 1.1rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.roleBtn:last-child { .roleBtn:last-child {
border-right: none; border-right: none;
} }
.roleBtn:hover { .roleBtn:hover {
color: #ffffff; color: #ffffff;
background: #1a0000; background: #1a0000;
} }
.roleBtnActive { .roleBtnActive {
background: #1a0000; background: #1a0000;
color: #ffffff; color: #ffffff;
box-shadow: inset 3px 0 0 #a30000; box-shadow: inset 3px 0 0 #a30000;
} }
/* /*
actions actions
*/ */
.resultCount { .resultCount {
font-size: 0.75rem; font-size: 0.75rem;
color: #444444; color: #444444;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
} }
.unlockAllBtn { .unlockAllBtn {
background: transparent; background: transparent;
border: 1px solid #4a0000; border: 1px solid #4a0000;
color: #a30000; color: #a30000;
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.unlockAllBtn:hover { .unlockAllBtn:hover {
background: #a30000; background: #a30000;
color: #ffffff; color: #ffffff;
border-color: #ff0000; border-color: #ff0000;
box-shadow: 0 0 12px rgba(163, 0, 0, 0.4); box-shadow: 0 0 12px rgba(163, 0, 0, 0.4);
} }
.lockAllBtn { .lockAllBtn {
background: transparent; background: transparent;
border: 1px solid #3d3d3d; border: 1px solid #3d3d3d;
color: #dddddd; color: #dddddd;
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.lockAllBtn:hover { .lockAllBtn:hover {
background: #000000; background: #000000;
color: #ffffff; color: #ffffff;
border-color: #303030; border-color: #303030;
box-shadow: 0 0 12px rgba(165, 165, 165, 0.4); box-shadow: 0 0 12px rgba(165, 165, 165, 0.4);
} }
.clearBtn { .clearBtn {
background: transparent; background: transparent;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
color: #444444; color: #444444;
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.clearBtn:hover { .clearBtn:hover {
color: #888888; color: #888888;
border-color: #333333; border-color: #333333;
} }
/* /*
cards cards
*/ */
.card { .card {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 0.85rem 0.5rem 0.7rem; padding: 0.85rem 0.5rem 0.7rem;
gap: 0.45rem; gap: 0.45rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
user-select: none; user-select: none;
} }
.card:hover { .card:hover {
border-color: #333333; border-color: #333333;
background: #111111; background: #111111;
} }
.cardUnlocked { .cardUnlocked {
border-color: #a30000; border-color: #a30000;
background: #0d0000; background: #0d0000;
} }
.cardUnlocked:hover { .cardUnlocked:hover {
border-color: #cc0000; border-color: #cc0000;
background: #110000; background: #110000;
box-shadow: 0 0 12px rgba(163, 0, 0, 0.2); box-shadow: 0 0 12px rgba(163, 0, 0, 0.2);
} }
.cardIcon { .cardIcon {
width: 64px; width: 64px;
height: 64px; height: 64px;
object-fit: contain; object-fit: contain;
filter: grayscale(1) brightness(0.35); filter: grayscale(1) brightness(0.35);
transition: filter 0.15s ease; transition: filter 0.15s ease;
} }
.cardUnlocked .cardIcon { .cardUnlocked .cardIcon {
filter: none; filter: none;
} }
.card:hover .cardIcon { .card:hover .cardIcon {
filter: grayscale(0.4) brightness(0.6); filter: grayscale(0.4) brightness(0.6);
} }
.cardUnlocked:hover .cardIcon { .cardUnlocked:hover .cardIcon {
filter: none; filter: none;
} }
.cardName { .cardName {
font-size: 0.62rem; font-size: 0.62rem;
text-align: center; text-align: center;
color: #444444; color: #444444;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.3; line-height: 1.3;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.cardUnlocked .cardName { .cardUnlocked .cardName {
color: #c9c9c9; color: #c9c9c9;
} }
.card:hover .cardName { .card:hover .cardName {
color: #666666; color: #666666;
} }
.cardUnlocked:hover .cardName { .cardUnlocked:hover .cardName {
color: #ffffff; color: #ffffff;
} }
.cardUnlocked { .cardUnlocked {
border-color: #a30000; border-color: #a30000;
background: #0d0000; background: #0d0000;
} }
.cardUnlocked:hover { .cardUnlocked:hover {
border-color: #cc0000; border-color: #cc0000;
background: #110000; background: #110000;
box-shadow: 0 0 16px rgba(163, 0, 0, 0.2); box-shadow: 0 0 16px rgba(163, 0, 0, 0.2);
} }
.cardUnlocked .cardIcon { .cardUnlocked .cardIcon {
filter: none; filter: none;
} }
.cardUnlocked:hover .cardIcon { .cardUnlocked:hover .cardIcon {
filter: none; filter: none;
} }
.cardUnlocked .cardName { .cardUnlocked .cardName {
color: #c9c9c9; color: #c9c9c9;
} }
.cardUnlocked:hover .cardName { .cardUnlocked:hover .cardName {
color: #ffffff; color: #ffffff;
} }
/* /*
@@ -288,89 +287,89 @@
*/ */
.rolePip { .rolePip {
font-size: 0.6rem; font-size: 0.6rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
color: #333333; color: #333333;
} }
.rolePipKiller { .rolePipKiller {
color: #3b0000; color: #3b0000;
} }
.cardUnlocked .rolePip { .cardUnlocked .rolePip {
color: #5e5e5e; color: #5e5e5e;
} }
.cardUnlocked .rolePipKiller { .cardUnlocked .rolePipKiller {
color: #7a0000; color: #7a0000;
} }
/* /*
pagination pagination
*/ */
.pagination { .pagination {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
justify-content: center; justify-content: center;
padding: 1.25rem 0 0; padding: 1.25rem 0 0;
flex-shrink: 0; flex-shrink: 0;
border-top: 1px solid #1a1a1a; border-top: 1px solid #1a1a1a;
} }
.pageBtn { .pageBtn {
background: #0d0d0d; background: #0d0d0d;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
color: #555555; color: #555555;
padding: 0.45rem 0.85rem; padding: 0.45rem 0.85rem;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
min-width: 36px; min-width: 36px;
text-align: center; text-align: center;
} }
.pageBtn:hover { .pageBtn:hover {
color: #ffffff; color: #ffffff;
border-color: #333333; border-color: #333333;
} }
.pageBtn:hover { .pageBtn:hover {
color: #ffffff; color: #ffffff;
border-color: #333333; border-color: #333333;
} }
.pageBtnActive { .pageBtnActive {
background: #1a0000; background: #1a0000;
border-color: #a30000; border-color: #a30000;
color: #ffffff; color: #ffffff;
} }
.pageBtnDisabled { .pageBtnDisabled {
opacity: 0.25; opacity: 0.25;
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
} }
.pageInfo { .pageInfo {
font-size: 0.72rem; font-size: 0.72rem;
color: #444444; color: #444444;
font-family: 'Oswald', sans-serif; font-family: 'Oswald', sans-serif;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
padding: 0 0.5rem; padding: 0 0.5rem;
} }
/* /*
empty state empty state
*/ */
.empty { .empty {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1; flex: 1;
color: #333333; color: #333333;
font-size: 0.9rem; font-size: 0.9rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }