feat: add customizations page
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import shared from '../../styles/shared.module.css';
|
||||
import styles from '../../styles/Customizations.module.css';
|
||||
import { CustomizationItem, CATEGORY_LABELS, CATEGORY_ORDER, getCosmeticIconUrl } from './types';
|
||||
|
||||
type Props = {
|
||||
charName: string;
|
||||
charCosmetics: Record<number, CustomizationItem[]>;
|
||||
unlockedSet: Set<string>;
|
||||
characterMap: Map<number, string>;
|
||||
onBack: () => void;
|
||||
onUnlockAll: () => void;
|
||||
onLockAll: () => void;
|
||||
onToggle: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function CharacterCosmetics({
|
||||
charName,
|
||||
charCosmetics,
|
||||
unlockedSet,
|
||||
characterMap,
|
||||
onBack,
|
||||
onUnlockAll,
|
||||
onLockAll,
|
||||
onToggle,
|
||||
}: Props) {
|
||||
const allItems = Object.values(charCosmetics).flat();
|
||||
const unlockedCount = allItems.filter(i => unlockedSet.has(i.id)).length;
|
||||
|
||||
return (
|
||||
<div className={styles.cosmeticsView}>
|
||||
<div className={styles.cosmeticsHeader}>
|
||||
<button className={styles.backBtn} onClick={onBack}>← Back</button>
|
||||
<span className={styles.cosmeticsCharName}>{charName}</span>
|
||||
<span className={shared.resultCount}>{unlockedCount} / {allItems.length} unlocked</span>
|
||||
<span className={shared.spacer} />
|
||||
<button className={shared.unlockAllBtn} onClick={onUnlockAll}>Unlock all</button>
|
||||
<button className={shared.lockAllBtn} onClick={onLockAll}>Lock all</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.cosmeticsBody}>
|
||||
{CATEGORY_ORDER.filter(cat => charCosmetics[cat]?.length > 0).map(cat => (
|
||||
<div key={cat} className={styles.categoryGroup}>
|
||||
<div className={styles.categoryTitle}>
|
||||
{CATEGORY_LABELS[cat] ?? `Category ${cat}`}
|
||||
</div>
|
||||
<div className={styles.gridInline}>
|
||||
{charCosmetics[cat].map(item => {
|
||||
const unlocked = unlockedSet.has(item.id);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
|
||||
onClick={() => onToggle(item.id)}
|
||||
>
|
||||
<img
|
||||
className={shared.cardIcon}
|
||||
src={getCosmeticIconUrl(item, characterMap)}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className={shared.cardName}>{item.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import shared from '../../styles/shared.module.css';
|
||||
import styles from '../../styles/Customizations.module.css';
|
||||
import { Character, RoleFilter } from './types';
|
||||
|
||||
type Props = {
|
||||
filteredChars: Character[];
|
||||
charSearch: string;
|
||||
charRole: RoleFilter;
|
||||
charFullyUnlocked: Map<number, boolean>;
|
||||
onSelect: (idx: number) => void;
|
||||
onSearchChange: (s: string) => void;
|
||||
onRoleChange: (r: RoleFilter) => void;
|
||||
onUnlockShownCosmetics: () => void;
|
||||
onLockShownCosmetics: () => void;
|
||||
};
|
||||
|
||||
const getCharIconUrl = (iconFilePath: string) => {
|
||||
const file = (iconFilePath.split('/').pop() ?? '').split('.')[0];
|
||||
return `/icons/character-icons/${file}.png`;
|
||||
};
|
||||
|
||||
export default function CharacterPicker({
|
||||
filteredChars,
|
||||
charSearch,
|
||||
charRole,
|
||||
charFullyUnlocked,
|
||||
onSelect,
|
||||
onSearchChange,
|
||||
onRoleChange,
|
||||
onUnlockShownCosmetics,
|
||||
onLockShownCosmetics,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className={shared.toolbar}>
|
||||
<input
|
||||
className={shared.searchInput}
|
||||
type="text"
|
||||
placeholder="Search character..."
|
||||
value={charSearch}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<div className={shared.roleFilter}>
|
||||
{(['all', 'survivors', 'killers'] as RoleFilter[]).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={`${shared.roleBtn} ${charRole === r ? shared.roleBtnActive : ''}`}
|
||||
onClick={() => onRoleChange(r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className={shared.spacer} />
|
||||
<span className={shared.resultCount}>{filteredChars.length} shown</span>
|
||||
<button className={shared.unlockAllBtn} onClick={onUnlockShownCosmetics}>
|
||||
Unlock Shown Cosmetics
|
||||
</button>
|
||||
<button className={shared.lockAllBtn} onClick={onLockShownCosmetics}>
|
||||
Lock Shown Cosmetics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.charGrid}>
|
||||
{filteredChars.map(char => {
|
||||
const fullyUnlocked = charFullyUnlocked.get(char.idx) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={char.idx}
|
||||
className={`${styles.charCard} ${fullyUnlocked ? styles.charCardFullyUnlocked : ''}`}
|
||||
onClick={() => onSelect(char.idx)}
|
||||
>
|
||||
<img
|
||||
className={styles.charCardIcon}
|
||||
src={getCharIconUrl(char.iconFilePath)}
|
||||
alt={char.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className={styles.charCardName}>{char.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import shared from '../../styles/shared.module.css';
|
||||
import styles from '../../styles/Customizations.module.css';
|
||||
import { CustomizationItem, Tab, getCosmeticIconUrl } from './types';
|
||||
|
||||
type Props = {
|
||||
tab: Tab;
|
||||
allFilteredItems: CustomizationItem[];
|
||||
pagedItems: CustomizationItem[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
search: string;
|
||||
unlockedSet: Set<string>;
|
||||
characterMap: Map<number, string>;
|
||||
onSearchChange: (s: string) => void;
|
||||
onUnlockAll: () => void;
|
||||
onLockAll: () => void;
|
||||
onUnlockPage: () => void;
|
||||
onLockPage: () => void;
|
||||
onToggle: (id: string) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
};
|
||||
|
||||
export default function FlatCategory({
|
||||
tab,
|
||||
allFilteredItems,
|
||||
pagedItems,
|
||||
page,
|
||||
totalPages,
|
||||
search,
|
||||
unlockedSet,
|
||||
characterMap,
|
||||
onSearchChange,
|
||||
onUnlockAll,
|
||||
onLockAll,
|
||||
onUnlockPage,
|
||||
onLockPage,
|
||||
onToggle,
|
||||
onPageChange,
|
||||
}: Props) {
|
||||
const buildPageNumbers = () => {
|
||||
const pages: number[] = [];
|
||||
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
||||
const end = Math.min(totalPages, start + 4);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={shared.toolbar}>
|
||||
<input
|
||||
className={shared.searchInput}
|
||||
type="text"
|
||||
placeholder={`Search ${tab}...`}
|
||||
value={search}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<span className={shared.spacer} />
|
||||
<span className={shared.resultCount}>{allFilteredItems.length} items</span>
|
||||
<button className={shared.unlockAllBtn} onClick={onUnlockAll}>
|
||||
Unlock all ({allFilteredItems.length})
|
||||
</button>
|
||||
<button className={shared.lockAllBtn} onClick={onLockAll}>
|
||||
Lock all
|
||||
</button>
|
||||
<button className={styles.pageActionBtn} onClick={onUnlockPage}>
|
||||
Unlock visible
|
||||
</button>
|
||||
<button className={styles.pageActionBtn} onClick={onLockPage}>
|
||||
Lock visible
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{allFilteredItems.length === 0 ? (
|
||||
<div className={shared.empty}>No items found</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{pagedItems.map(item => {
|
||||
const unlocked = unlockedSet.has(item.id);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`${shared.card} ${unlocked ? shared.cardUnlocked : ''}`}
|
||||
onClick={() => onToggle(item.id)}
|
||||
>
|
||||
<img
|
||||
className={shared.cardIcon}
|
||||
src={getCosmeticIconUrl(item, characterMap)}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className={shared.cardName}>{item.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className={shared.pagination}>
|
||||
<button
|
||||
className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`}
|
||||
onClick={() => onPageChange(1)}
|
||||
>«</button>
|
||||
<button
|
||||
className={`${shared.pageBtn} ${page === 1 ? shared.pageBtnDisabled : ''}`}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>‹</button>
|
||||
|
||||
{buildPageNumbers().map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`${shared.pageBtn} ${n === page ? shared.pageBtnActive : ''}`}
|
||||
onClick={() => onPageChange(n)}
|
||||
>{n}</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>›</button>
|
||||
<button
|
||||
className={`${shared.pageBtn} ${page === totalPages ? shared.pageBtnDisabled : ''}`}
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
>»</button>
|
||||
|
||||
<span className={shared.pageInfo}>{page} / {totalPages}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useInventoryStore } from '@/store/useInventoryStore';
|
||||
|
||||
import shared from '../../styles/shared.module.css';
|
||||
import styles from '../../styles/Customizations.module.css';
|
||||
|
||||
import { getFileName, cleanFolderName, isKiller } from '../lib/utils';
|
||||
import { Character, CustomizationItem, RoleFilter, Tab, TAB_CATEGORIES, CATEGORY_ORDER } from './types';
|
||||
import CharacterPicker from './CharacterPicker';
|
||||
import CharacterCosmetics from './CharacterCosmetics';
|
||||
import FlatCategory from './FlatCategory';
|
||||
|
||||
/*
|
||||
constants
|
||||
*/
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
|
||||
export default function CustomizationsPage() {
|
||||
const store = useInventoryStore();
|
||||
|
||||
const [allItems, setAllItems] = useState<CustomizationItem[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [tab, setTab] = useState<Tab>('cosmetics');
|
||||
const [selectedChar, setSelectedChar] = useState<number | null>(null);
|
||||
const [charSearch, setCharSearch] = useState('');
|
||||
const [charRole, setCharRole] = useState<RoleFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/data/customization_items.json').then(r => r.json()).catch(() => []),
|
||||
fetch('/data/characters.json').then(r => r.json()).catch(() => []),
|
||||
]).then(([items, chars]) => {
|
||||
setAllItems(items);
|
||||
setCharacters(chars);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setPage(1); }, [tab, search]);
|
||||
|
||||
/*
|
||||
derived data
|
||||
*/
|
||||
const characterMap = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
characters.forEach(c => m.set(c.idx, c.name));
|
||||
return m;
|
||||
}, [characters]);
|
||||
|
||||
const unlockedSet = useMemo(
|
||||
() => new Set(store.unlockedCustomizations),
|
||||
[store.unlockedCustomizations]
|
||||
);
|
||||
|
||||
const itemsByCharacter = useMemo(() => {
|
||||
const map = new Map<number, CustomizationItem[]>();
|
||||
allItems.forEach(item => {
|
||||
if (item.category >= 1 && item.category <= 7 && item.associatedCharacter > -1) {
|
||||
if (!map.has(item.associatedCharacter)) map.set(item.associatedCharacter, []);
|
||||
map.get(item.associatedCharacter)!.push(item);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allItems]);
|
||||
|
||||
const charFullyUnlocked = useMemo(() => {
|
||||
const result = new Map<number, boolean>();
|
||||
characters.forEach(char => {
|
||||
const items = itemsByCharacter.get(char.idx) ?? [];
|
||||
result.set(char.idx, items.length > 0 && items.every(i => unlockedSet.has(i.id)));
|
||||
});
|
||||
return result;
|
||||
}, [characters, itemsByCharacter, unlockedSet]);
|
||||
|
||||
const filteredChars = useMemo(() => {
|
||||
return characters.filter(c => {
|
||||
if (charRole === 'survivors' && isKiller(c.idx)) return false;
|
||||
if (charRole === 'killers' && !isKiller(c.idx)) return false;
|
||||
if (charSearch.trim() && !c.name.toLowerCase().includes(charSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
}, [characters, charRole, charSearch]);
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
if (tab === 'cosmetics') return [];
|
||||
const cat = TAB_CATEGORIES[tab];
|
||||
if (cat === undefined) return [];
|
||||
return allItems.filter(item => {
|
||||
if (item.category !== cat) return false;
|
||||
if (search.trim() && !item.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
}, [allItems, tab, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(flatItems.length / PAGE_SIZE));
|
||||
const pagedItems = useMemo(
|
||||
() => flatItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[flatItems, page]
|
||||
);
|
||||
|
||||
const charCosmetics = useMemo(() => {
|
||||
if (selectedChar === null) return {};
|
||||
const items = itemsByCharacter.get(selectedChar) ?? [];
|
||||
const groups: Record<number, CustomizationItem[]> = {};
|
||||
CATEGORY_ORDER.forEach(cat => {
|
||||
const group = items.filter(i => i.category === cat);
|
||||
if (group.length > 0) groups[cat] = group;
|
||||
});
|
||||
return groups;
|
||||
}, [itemsByCharacter, selectedChar]);
|
||||
|
||||
/*
|
||||
lock/unlock helpers
|
||||
*/
|
||||
const mergeUnlock = (ids: string[]) => {
|
||||
const merged = Array.from(new Set([...store.unlockedCustomizations, ...ids]));
|
||||
store.unlockAllInCategory('customizations', merged);
|
||||
};
|
||||
|
||||
const removeLock = (ids: string[]) => {
|
||||
const toRemove = new Set(ids);
|
||||
const remaining = store.unlockedCustomizations.filter(id => !toRemove.has(id));
|
||||
store.unlockAllInCategory('customizations', remaining);
|
||||
};
|
||||
|
||||
/*
|
||||
handlers
|
||||
*/
|
||||
const handleToggle = (id: string) => store.toggleItem(id, 'customizations');
|
||||
|
||||
const handleUnlockShownCosmetics = () => {
|
||||
const charSet = new Set(filteredChars.map(c => c.idx));
|
||||
mergeUnlock(
|
||||
allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id)
|
||||
);
|
||||
};
|
||||
|
||||
const handleLockShownCosmetics = () => {
|
||||
const charSet = new Set(filteredChars.map(c => c.idx));
|
||||
removeLock(
|
||||
allItems.filter(i => charSet.has(i.associatedCharacter) && i.category >= 1 && i.category <= 7).map(i => i.id)
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnlockCharCosmetics = () => mergeUnlock(Object.values(charCosmetics).flat().map(i => i.id));
|
||||
const handleLockCharCosmetics = () => removeLock(Object.values(charCosmetics).flat().map(i => i.id));
|
||||
|
||||
const handleUnlockAll = () => mergeUnlock(flatItems.map(i => i.id));
|
||||
const handleLockAll = () => removeLock(flatItems.map(i => i.id));
|
||||
|
||||
const handleUnlockPage = () => mergeUnlock(pagedItems.map(i => i.id));
|
||||
const handleLockPage = () => removeLock(pagedItems.map(i => i.id));
|
||||
|
||||
return (
|
||||
<div className={shared.container}>
|
||||
<header className={shared.header}>
|
||||
<div>
|
||||
<h1 className={shared.title}>Customizations</h1>
|
||||
<p className={shared.subtitle}>
|
||||
{store.unlockedCustomizations.length} of {allItems.length || '-'} unlocked
|
||||
</p>
|
||||
</div>
|
||||
<button className={shared.clearBtn} onClick={() => store.clearCategory('customizations')}>
|
||||
Clear All
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.tabs}>
|
||||
{(['cosmetics', 'charms', 'badges', 'banners', 'portraits'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
|
||||
onClick={() => { setTab(t); setSelectedChar(null); }}
|
||||
>
|
||||
{t === 'cosmetics' ? 'Character Cosmetics' : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'cosmetics' && selectedChar === null && (
|
||||
<CharacterPicker
|
||||
filteredChars={filteredChars}
|
||||
charSearch={charSearch}
|
||||
charRole={charRole}
|
||||
charFullyUnlocked={charFullyUnlocked}
|
||||
onSelect={setSelectedChar}
|
||||
onSearchChange={setCharSearch}
|
||||
onRoleChange={setCharRole}
|
||||
onUnlockShownCosmetics={handleUnlockShownCosmetics}
|
||||
onLockShownCosmetics={handleLockShownCosmetics}
|
||||
/>
|
||||
)}
|
||||
{tab === 'cosmetics' && selectedChar !== null && (
|
||||
<CharacterCosmetics
|
||||
charName={characterMap.get(selectedChar) ?? selectedChar.toString()}
|
||||
charCosmetics={charCosmetics}
|
||||
unlockedSet={unlockedSet}
|
||||
characterMap={characterMap}
|
||||
onBack={() => setSelectedChar(null)}
|
||||
onUnlockAll={handleUnlockCharCosmetics}
|
||||
onLockAll={handleLockCharCosmetics}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
)}
|
||||
{tab !== 'cosmetics' && (
|
||||
<FlatCategory
|
||||
tab={tab}
|
||||
allFilteredItems={flatItems}
|
||||
pagedItems={pagedItems}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
search={search}
|
||||
unlockedSet={unlockedSet}
|
||||
characterMap={characterMap}
|
||||
onSearchChange={setSearch}
|
||||
onUnlockAll={handleUnlockAll}
|
||||
onLockAll={handleLockAll}
|
||||
onUnlockPage={handleUnlockPage}
|
||||
onLockPage={handleLockPage}
|
||||
onToggle={handleToggle}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type CustomizationItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconFilePath: string;
|
||||
category: number;
|
||||
associatedCharacter: number;
|
||||
};
|
||||
|
||||
export type Character = {
|
||||
idx: number;
|
||||
name: string;
|
||||
iconFilePath: string;
|
||||
};
|
||||
|
||||
export type Tab = 'cosmetics' | 'charms' | 'badges' | 'banners' | 'portraits';
|
||||
export type RoleFilter = 'all' | 'survivors' | 'killers';
|
||||
|
||||
export const CATEGORY_LABELS: Record<number, string> = {
|
||||
1: 'Heads', 2: 'Torsos', 3: 'Legs',
|
||||
4: 'Heads', 5: 'Bodies', 6: 'Weapons', 7: 'Outfits',
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER = [7, 1, 4, 2, 3, 5, 6];
|
||||
|
||||
export const TAB_CATEGORIES: Partial<Record<Tab, number>> = {
|
||||
charms: 8, badges: 9, banners: 10, portraits: 11,
|
||||
};
|
||||
|
||||
export const getCosmeticIconUrl = (
|
||||
item: CustomizationItem,
|
||||
characterMap: Map<number, string>
|
||||
): string => {
|
||||
const file = (item.iconFilePath.split('/').pop() ?? '').split('.')[0];
|
||||
|
||||
switch (item.category) {
|
||||
case 8: return `/icons/customization/charms/${file}.png`;
|
||||
case 9: return `/icons/customization/badges/${file}.png`;
|
||||
case 10: return `/icons/customization/banners/${file}.png`;
|
||||
case 11: return `/icons/customization/portrait-backgrounds/${file}.png`;
|
||||
}
|
||||
|
||||
const subfolder =
|
||||
item.category === 1 || item.category === 4 ? 'heads' :
|
||||
item.category === 2 ? 'torsos' :
|
||||
item.category === 3 ? 'legs' :
|
||||
item.category === 5 ? 'bodys' :
|
||||
item.category === 6 ? 'weapons' : 'outfits';
|
||||
|
||||
const charName = characterMap.get(item.associatedCharacter);
|
||||
const charFolder = (charName ?? item.associatedCharacter.toString())
|
||||
.replace(/[\\/:*?"<>|]/g, '_');
|
||||
|
||||
return `/icons/customization/characters/${charFolder}/${subfolder}/${file}.png`;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
tabs
|
||||
*/
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
color: #444444;
|
||||
padding: 0.7rem 1.4rem;
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: #ffffff;
|
||||
border-bottom-color: #a30000;
|
||||
}
|
||||
|
||||
/*
|
||||
page btns
|
||||
*/
|
||||
.pageActionBtn {
|
||||
background: transparent;
|
||||
border: 1px solid #1e1e1e;
|
||||
color: #3a3a3a;
|
||||
padding: 0.6rem 1rem;
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.pageActionBtn:hover {
|
||||
color: #666666;
|
||||
border-color: #2e2e2e;
|
||||
}
|
||||
|
||||
/*
|
||||
char picker
|
||||
*/
|
||||
.charGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.charCard {
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #1a1a1a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.5rem 0.6rem;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.charCard:hover {
|
||||
border-color: #333333;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.charCardSelected {
|
||||
border-color: #a30000;
|
||||
background: #0d0000;
|
||||
}
|
||||
|
||||
.charCardIcon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.charCardName {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
color: #555555;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.charCardSelected .charCardName {
|
||||
color: #c9c9c9;
|
||||
}
|
||||
|
||||
.charCardFullyUnlocked {
|
||||
border-color: #6b4c00;
|
||||
background: #0d0900;
|
||||
}
|
||||
|
||||
.charCardFullyUnlocked:hover {
|
||||
border-color: #b37a00;
|
||||
box-shadow: 0 0 18px rgba(160, 110, 0, 0.3);
|
||||
}
|
||||
|
||||
.charCardFullyUnlocked .charCardName {
|
||||
color: #9a7020;
|
||||
}
|
||||
|
||||
/*
|
||||
sel view
|
||||
*/
|
||||
.cosmeticsView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cosmeticsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
background: transparent;
|
||||
border: 1px solid #1a1a1a;
|
||||
color: #555555;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: #ffffff;
|
||||
border-color: #333333;
|
||||
}
|
||||
|
||||
.cosmeticsCharName {
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cosmeticsBody {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.categoryGroup {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.categoryTitle {
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #444444;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
grid
|
||||
*/
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.gridInline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
Reference in New Issue
Block a user