From 8066ad44342ca2a6ddc9387f5ff306dd006f2592 Mon Sep 17 00:00:00 2001 From: neru Date: Thu, 18 Jun 2026 22:42:34 -0300 Subject: [PATCH] feat: add DLC page --- app/dlcs/page.tsx | 143 +++++++++++++++++++++++++++++++++ app/dlcs/types.ts | 20 +++++ lib/utils.ts | 15 +++- styles/Dlcs.module.css | 174 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 app/dlcs/page.tsx create mode 100644 app/dlcs/types.ts create mode 100644 styles/Dlcs.module.css diff --git a/app/dlcs/page.tsx b/app/dlcs/page.tsx new file mode 100644 index 0000000..661517a --- /dev/null +++ b/app/dlcs/page.tsx @@ -0,0 +1,143 @@ +'use client'; + +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 { DLC, isNamedDLC } from '@/lib/utils'; + +const PLATFORM_FILTER_LABELS: Record = { all: 'All', steam: 'Steam', epic: 'Epic', xbox: 'Xbox' }; + +export default function DlcsPage() { + const store = useInventoryStore(); + + const [allDlcs, setAllDlcs] = useState([]); + const [search, setSearch] = useState(''); + const [platformFilter, setPlatformFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all'); + + useEffect(() => { + fetch('/data/dlcs.json') + .then(r => r.json()) + .then((data: DLC[]) => setAllDlcs(data.filter(isNamedDLC))) + .catch(() => []); + }, []); + + 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 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 handleUnlockAll = () => { + store.unlockAllInCategory('dlcs', allDlcs.map(d => d.id)); + }; + + return ( +
+
+
+

DLCs

+

+ {store.unlockedDLCs.length} of {allDlcs.length || '-'} dlcs unlocked +

+
+
+ + +
+
+ +
+ setSearch(e.target.value)} + /> + +
+ {(Object.keys(PLATFORM_FILTER_LABELS) as PlatformFilter[]).map(p => ( + + ))} +
+ +
+ {(['all', 'unlocked', 'locked'] as const).map(s => ( + + ))} +
+ + + {filtered.length} shown + + + +
+ + {filtered.length === 0 ? ( +
No DLCs match
+ ) : ( +
+ {filtered.map(dlc => { + const unlocked = store.unlockedDLCs.includes(dlc.id); + return ( +
handleToggle(dlc.id)} + > + {dlc.name} +
+ ST + EP + XB +
+
+ ); + })} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/dlcs/types.ts b/app/dlcs/types.ts new file mode 100644 index 0000000..a4d580f --- /dev/null +++ b/app/dlcs/types.ts @@ -0,0 +1,20 @@ +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'; + +export const isOnEpic = (dlc: DLC) => + dlc.dlcIds.epic !== 'FFFFFFFFFFFFFFFF' && dlc.dlcIds.epic !== '0'; + +export const isOnXbox = (dlc: DLC) => + 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; +}; \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 7462901..33ca239 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,3 +1,13 @@ +export type DLC = { + id: string; + name: string | null; + dlcIds: { + steam: string; + epic: string; + grdk: string; + }; +}; + export const getFileName = (iconFilePath: string): string => { const base = iconFilePath.split('/').pop() ?? ''; return base.split('.')[0]; @@ -6,4 +16,7 @@ export const getFileName = (iconFilePath: string): string => { export const cleanFolderName = (name: string): string => name.replace(/[\\/:*?"<>|]/g, '_'); -export const isKiller = (idx: number): boolean => idx >= 268435456; \ No newline at end of file +export const isKiller = (idx: number): boolean => idx >= 268435456; + +export const isNamedDLC = (dlc: DLC): dlc is DLC & { name: string } => + !!dlc.name && !dlc.name.startsWith('@'); \ No newline at end of file diff --git a/styles/Dlcs.module.css b/styles/Dlcs.module.css new file mode 100644 index 0000000..c9c5759 --- /dev/null +++ b/styles/Dlcs.module.css @@ -0,0 +1,174 @@ +/* + 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; +} + +.overrideToggle:hover { + border-color: #333333; +} + +.overrideToggleActive { + border-color: #4a0000; + background: #0d0000; +} + +.overrideToggleActive:hover { + 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; +} + +.overrideToggleActive .overrideIndicator { + 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; +} + +.overrideToggleActive .overrideLabel { + color: #c9c9c9; +} + +.overrideWarning { + font-size: 0.65rem; + color: #5a0000; + font-family: 'Roboto Condensed', sans-serif; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.overrideToggleActive .overrideWarning { + 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; +} + +/* + 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; +} + +.card:hover { + background: #111111; + border-left-color: #330000; +} + +.cardUnlocked { + 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); +} + +.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; +} + +.cardUnlocked .cardName { + color: #ffffff; +} + +.card:hover .cardName { + color: #666666; +} + +.cardUnlocked:hover .cardName { + color: #ffffff; +} + +/* + platform badges +*/ +.platforms { + 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; +} + +.badgeOff { + color: #1e1e1e; + border-color: #181818; +} + +.badgeSteam { + color: #5a9cc4; + border-color: #2a4a66; +} + +.badgeEpic { + color: #888888; + border-color: #3a3a3a; +} + +.badgeXbox { + color: #4a9e40; + border-color: #225520; +} \ No newline at end of file