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