+
+
+
+
+
+ Icon
+ |
+ {renderSortHeader('id', 'Title ID')}
+ {renderSortHeader('name', 'Name')}
+ {renderSortHeader('size', 'Size')}
+ {renderSortHeader('releaseDate', 'Release Date')}
+
+ Related Content
+ |
+ |
+
+
+
+ {items.map((item) => (
+ handleDetails(item.id)}
+ className="group hover:bg-muted/50 active:bg-muted transition-colors cursor-pointer"
+ >
+
+
+ {
+ const img = e.target as HTMLImageElement;
+ img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" ry="2"%3E%3C/rect%3E%3Ccircle cx="8.5" cy="8.5" r="1.5"%3E%3C/circle%3E%3Cpolyline points="21 15 16 10 5 21"%3E%3C/polyline%3E%3C/svg%3E';
+ }}
+ />
+
+ |
+
+
+ {item.id}
+
+ {item.version && (
+
+ v{item.version}
+
+ )}
+ |
+
+
+ {item.name || 'Unknown Title'}
+
+ |
+
+
+ {formatFileSize(item.size)}
+
+ |
+
+
+ {formatDate(item.releaseDate)}
+
+ |
+
+ {getContentBadges(item.id)}
+ |
+
+
+ |
+
+ ))}
+
+
+
+
+ {items.length > 0 ? (
+
+ ) : (
+
+ No items found
+
+ )}
+
+ {selectedId && (
+
+
+ setSelectedId(null)}
+ />
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..23caf83
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -0,0 +1,85 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { AlertTriangle, RefreshCw } from 'lucide-react';
+import { useUserPreferences } from '../store/userPreferences';
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class ErrorBoundary extends Component {
+ public state: State = {
+ hasError: false,
+ error: null
+ };
+
+ public static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('Uncaught error:', error, errorInfo);
+ }
+
+ private handleReset = () => {
+ const { resetDataSources } = useUserPreferences.getState();
+ resetDataSources();
+ window.location.reload();
+ };
+
+ public render() {
+ if (this.state.hasError) {
+ const isDataLoadError = this.state.error?.message?.includes('Failed to load game data');
+
+ return (
+
+
+
+
+
Something went wrong
+
+
+
+ {this.state.error?.message || 'An unexpected error occurred'}
+
+
+
+ {isDataLoadError && (
+
+ )}
+
+
+
+
+ {isDataLoadError && (
+
+
💡 Tip
+
+ If you've modified the data source URLs in settings, try resetting them to their default values.
+ This often resolves loading issues.
+
+
+ )}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
\ No newline at end of file
diff --git a/src/components/ErrorLog.tsx b/src/components/ErrorLog.tsx
new file mode 100644
index 0000000..ed21a1f
--- /dev/null
+++ b/src/components/ErrorLog.tsx
@@ -0,0 +1,90 @@
+import { useState, type ReactNode } from 'react';
+import { AlertTriangle, X } from 'lucide-react';
+import { logger, type LogEntry, type LogDetails } from '../utils/logger';
+
+interface ErrorLogProps {
+ onClose: () => void;
+}
+
+function formatLogDetails(details: LogDetails): ReactNode {
+ try {
+ return (
+
+ {JSON.stringify(details, null, 2)}
+
+ );
+ } catch {
+ return (
+
+ [Error formatting details]
+
+ );
+ }
+}
+
+export function ErrorLog({ onClose }: ErrorLogProps) {
+ const [filter, setFilter] = useState<'all' | 'error'>('error');
+ const logs = filter === 'all' ? logger.getLogs() : logger.getErrorLogs();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {logs.length === 0 ? (
+
No logs to display
+ ) : (
+
+ {logs.map((log: LogEntry, index) => (
+
+
+
+ {log.level.toUpperCase()}
+
+
+ {new Date(log.timestamp).toLocaleString()}
+
+
+
{log.message}
+ {log.details && formatLogDetails(log.details)}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/GameDetails.tsx b/src/components/GameDetails.tsx
new file mode 100644
index 0000000..33ee0e9
--- /dev/null
+++ b/src/components/GameDetails.tsx
@@ -0,0 +1,175 @@
+import { useState } from 'react';
+import { X, Package, Download, ExternalLink } from 'lucide-react';
+import { ContentItem } from '../types';
+import { getVisualAssets } from '../utils/contentGrouping';
+import { ScreenshotGallery } from './ScreenshotGallery';
+import { formatFileSize, formatDate } from '../utils/formatters';
+import { useUserPreferences } from '../store/userPreferences';
+
+interface ContentListProps {
+ items: ContentItem[];
+ maxVisible?: number;
+ type: 'update' | 'dlc';
+}
+
+function ContentList({ items, maxVisible = 5, type }: ContentListProps) {
+ const [showAll, setShowAll] = useState(false);
+ const visibleItems = showAll ? items : items.slice(0, maxVisible);
+ const hasMore = items.length > maxVisible;
+
+ return (
+
+ {visibleItems.map(item => (
+
+
+ {item.id}
+
+ {item.name &&
{item.name}
}
+ {item.version && type === 'update' && (
+
+ Version {item.version}
+
+ )}
+ {item.size && (
+
+ Size: {formatFileSize(item.size)}
+
+ )}
+ {item.releaseDate && (
+
+ Released: {formatDate(item.releaseDate)}
+
+ )}
+
+ ))}
+ {hasMore && (
+
+ )}
+
+ );
+}
+
+interface GameDetailsProps {
+ content: {
+ base: ContentItem | null;
+ updates: ContentItem[];
+ dlcs: ContentItem[];
+ };
+ onClose: () => void;
+}
+
+export function GameDetails({ content, onClose }: GameDetailsProps) {
+ const { base, updates, dlcs } = content;
+ const { maxDlcDisplay, maxUpdateDisplay } = useUserPreferences();
+
+ if (!base) return null;
+
+ const assets = getVisualAssets(base.id);
+
+ return (
+
+
+
+ {/* Banner */}
+
+
{
+ const img = e.target as HTMLImageElement;
+ img.style.display = 'none';
+ }}
+ />
+
+
+ {/* Content Info */}
+
+
+
+
{
+ const img = e.target as HTMLImageElement;
+ img.style.display = 'none';
+ }}
+ />
+
+
Base Game
+
{base.id}
+
+
+ {base.name &&
{base.name}
}
+ {base.size && (
+
+ Size: {formatFileSize(base.size)}
+
+ )}
+ {base.releaseDate && (
+
+ Released: {formatDate(base.releaseDate)}
+
+ )}
+
+
+ {updates.length > 0 && (
+
+
+
+
Updates ({updates.length})
+
+
+
+ )}
+
+ {dlcs.length > 0 && (
+
+
+
+
DLCs ({dlcs.length})
+
+
+
+ )}
+
+
+ {/* Screenshots */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 0000000..809afea
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,83 @@
+import { useState } from 'react';
+import { Database, AlertTriangle, Sun, Moon, Settings as SettingsIcon } from 'lucide-react';
+import { ErrorLog } from './ErrorLog';
+import { Settings } from './Settings';
+import { useUserPreferences } from '../store/userPreferences';
+import { logger } from '../utils/logger';
+
+interface HeaderProps {
+ onToggleTheme: () => void;
+}
+
+export function Header({ onToggleTheme }: HeaderProps) {
+ const [showErrorLog, setShowErrorLog] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+ const { isDark, showLogs } = useUserPreferences();
+ const errorCount = logger.getErrorLogs().length;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx
new file mode 100644
index 0000000..4defcd7
--- /dev/null
+++ b/src/components/Pagination.tsx
@@ -0,0 +1,100 @@
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { PaginationProps } from '../types';
+import { useUserPreferences } from '../store/userPreferences';
+
+export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
+ const { itemsPerPage, setItemsPerPage } = useUserPreferences();
+ const showPages = 5;
+ const halfShowPages = Math.floor(showPages / 2);
+ let startPage = Math.max(currentPage - halfShowPages, 1);
+ let endPage = Math.min(startPage + showPages - 1, totalPages);
+
+ if (endPage - startPage + 1 < showPages) {
+ startPage = Math.max(endPage - showPages + 1, 1);
+ }
+
+ const pages = Array.from(
+ { length: endPage - startPage + 1 },
+ (_, i) => startPage + i
+ );
+
+ return (
+
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+
+
+
+ {startPage > 1 && (
+ <>
+
+ {startPage > 2 && (
+ ...
+ )}
+ >
+ )}
+
+ {pages.map(page => (
+
+ ))}
+
+ {endPage < totalPages && (
+ <>
+ {endPage < totalPages - 1 && (
+ ...
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ScreenshotGallery.tsx b/src/components/ScreenshotGallery.tsx
new file mode 100644
index 0000000..1c0f78d
--- /dev/null
+++ b/src/components/ScreenshotGallery.tsx
@@ -0,0 +1,51 @@
+import { Gallery, Item } from 'react-photoswipe-gallery';
+import 'photoswipe/style.css';
+
+interface ScreenshotGalleryProps {
+ screenshots: string[];
+}
+
+export function ScreenshotGallery({ screenshots }: ScreenshotGalleryProps) {
+ return (
+
+
Screenshots
+
+
+
+ {screenshots.map((url, index) => (
+
-
+ {({ ref, open }) => (
+
+
{
+ const img = e.target as HTMLImageElement;
+ img.style.display = 'none';
+ }}
+ />
+
+
+ View Fullscreen
+
+
+
+ )}
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
new file mode 100644
index 0000000..44f30d9
--- /dev/null
+++ b/src/components/SearchBar.tsx
@@ -0,0 +1,43 @@
+import { Search, X } from 'lucide-react';
+
+interface SearchBarProps {
+ value: string;
+ onChange: (value: string) => void;
+ resultCount?: number;
+ totalCount?: number;
+}
+
+export function SearchBar({ value, onChange, resultCount, totalCount }: SearchBarProps) {
+ return (
+
+
+
+
+
onChange(e.target.value)}
+ className="block w-full pl-10 pr-24 py-3 bg-card border border-border rounded-lg
+ text-foreground placeholder-muted-foreground
+ focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
+ hover:border-primary/50 transition-all duration-200"
+ placeholder="Search by Title ID or Name..."
+ />
+
+ {resultCount !== undefined && totalCount !== undefined && (
+
+ {resultCount.toLocaleString()} / {totalCount.toLocaleString()}
+
+ )}
+ {value && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
new file mode 100644
index 0000000..a7eb591
--- /dev/null
+++ b/src/components/Settings.tsx
@@ -0,0 +1,274 @@
+import { useState } from 'react';
+import { Settings as SettingsIcon, X, ChevronDown, ChevronUp } from 'lucide-react';
+import { useUserPreferences } from '../store/userPreferences';
+
+interface SettingsProps {
+ onClose: () => void;
+}
+
+export function Settings({ onClose }: SettingsProps) {
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const {
+ itemsPerPage,
+ setItemsPerPage,
+ searchPrecision,
+ setSearchPrecision,
+ showLogs,
+ setShowLogs,
+ showVersionHistory,
+ setShowVersionHistory,
+ autoRefreshInterval,
+ setAutoRefreshInterval,
+ maxDlcDisplay,
+ setMaxDlcDisplay,
+ maxUpdateDisplay,
+ setMaxUpdateDisplay,
+ dataSources,
+ setDataSource,
+ resetDataSources
+ } = useUserPreferences();
+
+ const handlePrecisionChange = (value: string) => {
+ setSearchPrecision(parseFloat(value));
+ };
+
+ return (
+
+
+
+
+
+ {/* Display Settings */}
+
+ Display Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Search Settings */}
+
+ Search Settings
+
+
+
+
handlePrecisionChange(e.target.value)}
+ className="w-full accent-primary"
+ />
+
+ Exact Match
+ Fuzzy Match
+
+
+ {searchPrecision <= 0.3 ? (
+ "High precision: Requires almost exact matches"
+ ) : searchPrecision <= 0.6 ? (
+ "Medium precision: Allows some typos and variations"
+ ) : (
+ "Low precision: Very forgiving, finds similar matches"
+ )}
+
+
+
+
+ {/* Feature Toggles */}
+
+ Features
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Advanced Settings */}
+
+
+
+ {showAdvanced && (
+
+
+
+ setDataSource('workingContent', e.target.value)}
+ className="w-full bg-muted border border-border rounded-lg px-3 py-2 text-sm hover:border-primary/50 transition-colors"
+ placeholder="Enter URL for working.txt"
+ />
+
+
+
+
+ setDataSource('titlesDb', e.target.value)}
+ className="w-full bg-muted border border-border rounded-lg px-3 py-2 text-sm hover:border-primary/50 transition-colors"
+ placeholder="Enter URL for titles_db.txt"
+ />
+
+
+
+
+
+
+
+
⚠️ Warning
+
+ Modifying these URLs may affect the application's functionality.
+ Only change them if you know what you're doing.
+
+
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/StatsCard.tsx b/src/components/StatsCard.tsx
new file mode 100644
index 0000000..4a5392c
--- /dev/null
+++ b/src/components/StatsCard.tsx
@@ -0,0 +1,28 @@
+import { type MouseEventHandler } from 'react';
+
+interface StatsCardProps {
+ title: string;
+ count: number;
+ isActive?: boolean;
+ onClick?: MouseEventHandler;
+}
+
+export function StatsCard({ title, count, isActive = false, onClick }: StatsCardProps) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx
new file mode 100644
index 0000000..65565b2
--- /dev/null
+++ b/src/components/TabNavigation.tsx
@@ -0,0 +1,42 @@
+import { TabNavigationProps } from '../types';
+import { Database, Download, Package } from 'lucide-react';
+
+export function TabNavigation({ activeTab, onTabChange, counts }: TabNavigationProps) {
+ const tabs = [
+ { id: 'base' as const, name: 'Base Games', icon: Database, count: counts?.base ?? 0 },
+ { id: 'update' as const, name: 'Updates', icon: Download, count: counts?.update ?? 0 },
+ { id: 'dlc' as const, name: 'DLC', icon: Package, count: counts?.dlc ?? 0 },
+ ];
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/useGameInfo.ts b/src/hooks/useGameInfo.ts
new file mode 100644
index 0000000..e6ef041
--- /dev/null
+++ b/src/hooks/useGameInfo.ts
@@ -0,0 +1,52 @@
+import { useState, useEffect } from 'react';
+import { GameInfo } from '../types';
+import { gameDataService } from '../services/gameData';
+import { logger } from '../utils/logger';
+
+export function useGameInfo(titleId: string | null) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!titleId) {
+ setData(null);
+ setError(null);
+ return;
+ }
+
+ let mounted = true;
+
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const gameInfo = await gameDataService.getGameData(titleId);
+
+ if (mounted) {
+ setData(gameInfo);
+ setLoading(false);
+ }
+ } catch (err) {
+ logger.error('Error in useGameInfo hook', {
+ titleId,
+ error: err instanceof Error ? err.message : 'Unknown error'
+ });
+
+ if (mounted) {
+ setError(err instanceof Error ? err : new Error('Failed to fetch game data'));
+ setLoading(false);
+ }
+ }
+ };
+
+ fetchData();
+
+ return () => {
+ mounted = false;
+ };
+ }, [titleId]);
+
+ return { data, loading, error };
+}
\ No newline at end of file
diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts
new file mode 100644
index 0000000..6a4d0d0
--- /dev/null
+++ b/src/hooks/useSearch.ts
@@ -0,0 +1,132 @@
+import { useMemo, useState, useCallback } from 'react';
+import Fuse from 'fuse.js';
+import { ContentItem, SearchOptions, SortField, SortDirection } from '../types';
+import { useUserPreferences } from '../store/userPreferences';
+
+const getSearchOptions = (precision: number): SearchOptions => ({
+ threshold: precision,
+ distance: Math.floor(100 * (1 + precision)),
+ minMatchCharLength: Math.max(2, Math.floor(4 * (1 - precision))),
+});
+
+function parseDate(dateStr: string | undefined): number {
+ if (!dateStr) return 0;
+ try {
+ const date = new Date(dateStr);
+ return isNaN(date.getTime()) ? 0 : date.getTime();
+ } catch {
+ return 0;
+ }
+}
+
+function sortItems(items: ContentItem[], field: SortField, direction: SortDirection): ContentItem[] {
+ return [...items].sort((a, b) => {
+ let comparison = 0;
+
+ switch (field) {
+ case 'id':
+ comparison = (a.id || '').localeCompare(b.id || '');
+ break;
+
+ case 'name': {
+ const nameA = a.name || 'Unknown Title';
+ const nameB = b.name || 'Unknown Title';
+
+ // Always put Unknown at the end regardless of sort direction
+ if (nameA === 'Unknown Title' && nameB !== 'Unknown Title') return 1;
+ if (nameB === 'Unknown Title' && nameA !== 'Unknown Title') return -1;
+
+ comparison = nameA.localeCompare(nameB);
+ break;
+ }
+
+ case 'releaseDate': {
+ const dateA = parseDate(a.releaseDate);
+ const dateB = parseDate(b.releaseDate);
+
+ // Always put items without dates at the end
+ if (!dateA && !dateB) return 0;
+ if (!dateA) return 1;
+ if (!dateB) return 1;
+
+ // Sort by date
+ if (dateA !== dateB) {
+ comparison = dateA - dateB;
+ } else {
+ // If dates are equal, sort by name as secondary criteria
+ const nameA = a.name || 'Unknown Title';
+ const nameB = b.name || 'Unknown Title';
+ comparison = nameA.localeCompare(nameB);
+ }
+ break;
+ }
+
+ case 'size': {
+ const sizeA = a.size || 0;
+ const sizeB = b.size || 0;
+
+ // Always put items without size at the end
+ if (sizeA === 0 && sizeB !== 0) return 1;
+ if (sizeB === 0 && sizeA !== 0) return -1;
+
+ comparison = sizeA - sizeB;
+ break;
+ }
+ }
+
+ return direction === 'asc' ? comparison : -comparison;
+ });
+}
+
+export function useSearch(items: ContentItem[]) {
+ const [query, setQuery] = useState('');
+ const [sortField, setSortField] = useState('releaseDate');
+ const [sortDirection, setSortDirection] = useState('desc');
+ const { searchPrecision } = useUserPreferences();
+
+ const searchOptions = useMemo(() => getSearchOptions(searchPrecision), [searchPrecision]);
+
+ const fuse = useMemo(() => new Fuse(items, {
+ keys: [
+ { name: 'id', weight: 2 },
+ { name: 'name', weight: 1 }
+ ],
+ ...searchOptions,
+ shouldSort: false,
+ includeScore: true,
+ ignoreLocation: true,
+ useExtendedSearch: true,
+ getFn: (obj, path) => {
+ const value = obj[path as keyof ContentItem];
+ return value?.toString() || '';
+ }
+ }), [items, searchOptions]);
+
+ const search = useCallback((searchQuery: string) => {
+ if (!searchQuery) return items;
+ return fuse.search(searchQuery).map(result => result.item);
+ }, [fuse, items]);
+
+ const toggleSort = useCallback((field: SortField) => {
+ if (field === sortField) {
+ setSortDirection(current => current === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortField(field);
+ setSortDirection('desc');
+ }
+ }, [sortField]);
+
+ const results = useMemo(() => {
+ const searchResults = search(query);
+ return sortItems(searchResults, sortField, sortDirection);
+ }, [search, query, sortField, sortDirection]);
+
+ return {
+ query,
+ setQuery,
+ results,
+ sortField,
+ sortDirection,
+ toggleSort,
+ };
+}
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..a6ac67d
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,48 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --background: 255 255 255;
+ --foreground: 15 23 42;
+
+ --muted: 241 245 249;
+ --muted-foreground: 100 116 139;
+
+ --card: 255 255 255;
+ --card-foreground: 15 23 42;
+
+ --border: 226 232 240;
+
+ --primary: 37 99 235;
+ --primary-foreground: 255 255 255;
+}
+
+:root[class~="dark"] {
+ --background: 15 23 42;
+ --foreground: 226 232 240;
+
+ --muted: 30 41 59;
+ --muted-foreground: 148 163 184;
+
+ --card: 30 41 59;
+ --card-foreground: 226 232 240;
+
+ --border: 51 65 85;
+
+ --primary: 59 130 246;
+ --primary-foreground: 255 255 255;
+}
+
+body {
+ @apply antialiased bg-background text-foreground;
+}
+
+.icon-container {
+ @apply relative w-16 h-16 flex-shrink-0;
+}
+
+.icon-container img {
+ @apply absolute inset-0 w-full h-full object-cover rounded-lg;
+ aspect-ratio: 1/1;
+}
\ No newline at end of file
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..1bee4b5
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import { ErrorBoundary } from './components/ErrorBoundary';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+);
\ No newline at end of file
diff --git a/src/services/gameData.ts b/src/services/gameData.ts
new file mode 100644
index 0000000..91bef9e
--- /dev/null
+++ b/src/services/gameData.ts
@@ -0,0 +1,227 @@
+import { GameInfo } from '../types';
+import { logger } from '../utils/logger';
+
+const API_CONFIG = {
+ baseUrls: [
+ 'https://api.nlib.cc',
+ 'https://api-nlib.vercel.app',
+ 'https://nlib-api.vercel.app'
+ ],
+ timeout: 5000,
+ retryDelay: 1000,
+ maxRetries: 2,
+ cacheExpiry: 24 * 60 * 60 * 1000 // 24 hours
+};
+
+// IndexedDB configuration
+const DB_CONFIG = {
+ name: 'NXWorkingDB',
+ version: 1,
+ storeName: 'gameData'
+};
+
+class GameDataService {
+ private db: IDBDatabase | null = null;
+
+ constructor() {
+ this.initDB().catch(error => {
+ logger.error('Failed to initialize IndexedDB', { error });
+ });
+ }
+
+ private async initDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version);
+
+ request.onerror = () => {
+ logger.error('IndexedDB access denied');
+ reject(new Error('IndexedDB access denied'));
+ };
+
+ request.onsuccess = (event) => {
+ this.db = (event.target as IDBOpenDBRequest).result;
+ resolve();
+ };
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(DB_CONFIG.storeName)) {
+ db.createObjectStore(DB_CONFIG.storeName, { keyPath: 'id' });
+ }
+ };
+ });
+ }
+
+ private async getCachedData(titleId: string): Promise {
+ if (!this.db) return null;
+
+ return new Promise((resolve) => {
+ const transaction = this.db!.transaction([DB_CONFIG.storeName], 'readonly');
+ const store = transaction.objectStore(DB_CONFIG.storeName);
+ const request = store.get(titleId);
+
+ request.onsuccess = () => {
+ const data = request.result;
+ if (!data || Date.now() - data.timestamp > API_CONFIG.cacheExpiry) {
+ resolve(null);
+ return;
+ }
+ resolve(data.gameInfo);
+ };
+
+ request.onerror = () => {
+ logger.warn('Failed to read from cache', { titleId });
+ resolve(null);
+ };
+ });
+ }
+
+ private async setCachedData(titleId: string, gameInfo: GameInfo): Promise {
+ if (!this.db) return;
+
+ return new Promise((resolve) => {
+ const transaction = this.db!.transaction([DB_CONFIG.storeName], 'readwrite');
+ const store = transaction.objectStore(DB_CONFIG.storeName);
+ const request = store.put({
+ id: titleId,
+ gameInfo,
+ timestamp: Date.now()
+ });
+
+ request.onsuccess = () => resolve();
+ request.onerror = () => {
+ logger.warn('Failed to write to cache', { titleId });
+ resolve();
+ };
+ });
+ }
+
+ private async fetchWithTimeout(url: string): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
+
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ 'Accept': 'application/json',
+ 'Cache-Control': 'no-cache'
+ }
+ });
+ clearTimeout(timeoutId);
+ return response;
+ } catch (error) {
+ clearTimeout(timeoutId);
+ throw error;
+ }
+ }
+
+ private normalizeGameData(rawData: any, titleId: string): GameInfo {
+ return {
+ id: titleId,
+ name: rawData.name || 'Unknown Title',
+ publisher: rawData.publisher || 'Unknown Publisher',
+ description: rawData.description || 'No description available',
+ size: typeof rawData.size === 'number' ? rawData.size : null,
+ version: rawData.version || 'Unknown',
+ releaseDate: rawData.release_date || null,
+ rating: rawData.rating || null,
+ categories: Array.isArray(rawData.categories) ? rawData.categories : [],
+ languages: Array.isArray(rawData.languages) ? rawData.languages : [],
+ screenshots: Array.isArray(rawData.screenshots) ? rawData.screenshots : []
+ };
+ }
+
+ async getGameData(titleId: string): Promise {
+ try {
+ // Try to get from cache first
+ const cachedData = await this.getCachedData(titleId);
+ if (cachedData) {
+ logger.info('Cache hit', { titleId });
+ return cachedData;
+ }
+
+ logger.info('Fetching game data', { titleId });
+
+ // Try each API endpoint
+ for (const baseUrl of API_CONFIG.baseUrls) {
+ for (let retry = 0; retry <= API_CONFIG.maxRetries; retry++) {
+ try {
+ if (retry > 0) {
+ await new Promise(resolve => setTimeout(resolve, API_CONFIG.retryDelay));
+ }
+
+ const response = await this.fetchWithTimeout(`${baseUrl}/nx/${titleId}`);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const gameInfo = this.normalizeGameData(data, titleId);
+
+ // Cache the successful response
+ await this.setCachedData(titleId, gameInfo);
+
+ logger.info('Successfully fetched and cached game data', { titleId });
+ return gameInfo;
+ } catch (error) {
+ logger.warn(`Attempt ${retry + 1} failed for ${baseUrl}`, {
+ titleId,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+
+ if (retry === API_CONFIG.maxRetries) {
+ continue; // Try next API endpoint
+ }
+ }
+ }
+ }
+
+ // If all attempts failed, throw error
+ throw new Error('All API endpoints failed');
+ } catch (error) {
+ logger.error('Failed to fetch game data', {
+ titleId,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+
+ // Return fallback data
+ return {
+ id: titleId,
+ name: 'Title Information Unavailable',
+ publisher: 'Unknown Publisher',
+ description: 'Game information is currently unavailable.',
+ size: null,
+ version: 'Unknown',
+ releaseDate: null,
+ rating: null,
+ categories: [],
+ languages: [],
+ screenshots: []
+ };
+ }
+ }
+
+ async clearCache(): Promise {
+ if (!this.db) return;
+
+ return new Promise((resolve) => {
+ const transaction = this.db!.transaction([DB_CONFIG.storeName], 'readwrite');
+ const store = transaction.objectStore(DB_CONFIG.storeName);
+ const request = store.clear();
+
+ request.onsuccess = () => {
+ logger.info('Cache cleared successfully');
+ resolve();
+ };
+
+ request.onerror = () => {
+ logger.warn('Failed to clear cache');
+ resolve();
+ };
+ });
+ }
+}
+
+export const gameDataService = new GameDataService();
\ No newline at end of file
diff --git a/src/services/statsService.ts b/src/services/statsService.ts
new file mode 100644
index 0000000..3e386ef
--- /dev/null
+++ b/src/services/statsService.ts
@@ -0,0 +1,21 @@
+import { logger } from '../utils/logger';
+
+interface StatsResponse {
+ total_downloads: number;
+}
+
+export async function getDownloadStats(titleId: string): Promise {
+ try {
+ const response = await fetch(`https://stats.ghostland.at/${titleId}/json`);
+ if (!response.ok) return null;
+
+ const data: StatsResponse = await response.json();
+ return data.total_downloads;
+ } catch (error) {
+ logger.error('Failed to fetch download stats', {
+ titleId,
+ errorMessage: error instanceof Error ? error.message : 'Unknown error'
+ });
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/store/userPreferences.ts b/src/store/userPreferences.ts
new file mode 100644
index 0000000..6e79b61
--- /dev/null
+++ b/src/store/userPreferences.ts
@@ -0,0 +1,101 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+
+interface DataSources {
+ workingContent: string;
+ titlesDb: string;
+}
+
+interface UserPreferences {
+ isDark: boolean;
+ itemsPerPage: number;
+ lastActiveTab: 'base' | 'update' | 'dlc';
+ searchPrecision: number;
+ showLogs: boolean;
+ showVersionHistory: boolean;
+ autoRefreshInterval: number | null;
+ maxDlcDisplay: number;
+ maxUpdateDisplay: number;
+ dataSources: DataSources;
+ setDarkMode: (isDark: boolean) => void;
+ setItemsPerPage: (count: number) => void;
+ setLastActiveTab: (tab: 'base' | 'update' | 'dlc') => void;
+ setSearchPrecision: (precision: number) => void;
+ setShowLogs: (show: boolean) => void;
+ setShowVersionHistory: (show: boolean) => void;
+ setAutoRefreshInterval: (interval: number | null) => void;
+ setMaxDlcDisplay: (count: number) => void;
+ setMaxUpdateDisplay: (count: number) => void;
+ setDataSource: (key: keyof DataSources, url: string) => void;
+ resetDataSources: () => void;
+}
+
+const DEFAULT_DATA_SOURCES: DataSources = {
+ workingContent: 'https://raw.githubusercontent.com/ghost-land/NX-Missing/refs/heads/main/data/working.txt',
+ titlesDb: 'https://raw.githubusercontent.com/ghost-land/NX-Missing/refs/heads/main/data/titles_db.txt'
+};
+
+const initialState = {
+ isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
+ itemsPerPage: 25,
+ lastActiveTab: 'base' as const,
+ searchPrecision: 0.4,
+ showLogs: false,
+ showVersionHistory: true,
+ autoRefreshInterval: null,
+ maxDlcDisplay: 5,
+ maxUpdateDisplay: 5,
+ dataSources: DEFAULT_DATA_SOURCES,
+};
+
+export const useUserPreferences = create()(
+ persist(
+ (set) => ({
+ ...initialState,
+ setDarkMode: (isDark) => set({ isDark }),
+ setItemsPerPage: (itemsPerPage) => set({ itemsPerPage }),
+ setLastActiveTab: (lastActiveTab) => set({ lastActiveTab }),
+ setSearchPrecision: (searchPrecision) => set({ searchPrecision }),
+ setShowLogs: (showLogs) => set({ showLogs }),
+ setShowVersionHistory: (showVersionHistory) => set({ showVersionHistory }),
+ setAutoRefreshInterval: (autoRefreshInterval) => set({ autoRefreshInterval }),
+ setMaxDlcDisplay: (maxDlcDisplay) => set({ maxDlcDisplay }),
+ setMaxUpdateDisplay: (maxUpdateDisplay) => set({ maxUpdateDisplay }),
+ setDataSource: (key, url) => set(state => ({
+ dataSources: {
+ ...state.dataSources,
+ [key]: url
+ }
+ })),
+ resetDataSources: () => set({ dataSources: DEFAULT_DATA_SOURCES }),
+ }),
+ {
+ name: 'nx-working-preferences',
+ version: 5,
+ storage: createJSONStorage(() => localStorage),
+ partialize: (state) => ({
+ isDark: state.isDark,
+ itemsPerPage: state.itemsPerPage,
+ lastActiveTab: state.lastActiveTab,
+ searchPrecision: state.searchPrecision,
+ showLogs: state.showLogs,
+ showVersionHistory: state.showVersionHistory,
+ autoRefreshInterval: state.autoRefreshInterval,
+ maxDlcDisplay: state.maxDlcDisplay,
+ maxUpdateDisplay: state.maxUpdateDisplay,
+ dataSources: state.dataSources,
+ }),
+ migrate: (persistedState: any, version) => {
+ if (version < 5) {
+ return {
+ ...initialState,
+ ...persistedState,
+ maxDlcDisplay: 5,
+ maxUpdateDisplay: 5,
+ };
+ }
+ return persistedState as UserPreferences;
+ },
+ }
+ )
+);
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..32cb67a
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,53 @@
+export interface ContentItem {
+ id: string;
+ uniqueId: string;
+ type: 'base' | 'update' | 'dlc';
+ version?: string;
+ name?: string;
+ size?: number;
+ releaseDate?: string;
+}
+
+export interface PaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+export type SortField = 'id' | 'name' | 'releaseDate' | 'size';
+export type SortDirection = 'asc' | 'desc';
+
+export interface SearchOptions {
+ threshold: number;
+ distance: number;
+ minMatchCharLength: number;
+}
+
+export interface SortOptions {
+ field: SortField;
+ direction: SortDirection;
+}
+
+export interface GameInfo {
+ id: string;
+ name: string;
+ publisher: string;
+ description: string;
+ size: number | null;
+ version: string;
+ releaseDate: string | null;
+ rating: string | null;
+ categories: string[];
+ languages: string[];
+ screenshots: string[];
+}
+
+export interface TabNavigationProps {
+ activeTab: 'base' | 'update' | 'dlc';
+ onTabChange: (tab: 'base' | 'update' | 'dlc') => void;
+ counts: {
+ base: number;
+ update: number;
+ dlc: number;
+ };
+}
\ No newline at end of file
diff --git a/src/utils/cache.ts b/src/utils/cache.ts
new file mode 100644
index 0000000..030fe1d
--- /dev/null
+++ b/src/utils/cache.ts
@@ -0,0 +1,52 @@
+import { logger } from './logger';
+
+const CACHE_PREFIX = 'nx-working-';
+const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
+
+export async function getCachedData(key: string): Promise {
+ try {
+ const item = localStorage.getItem(`${CACHE_PREFIX}${key}`);
+ if (!item) return null;
+
+ const { value, timestamp } = JSON.parse(item);
+ if (Date.now() - timestamp > CACHE_EXPIRY) {
+ localStorage.removeItem(`${CACHE_PREFIX}${key}`);
+ return null;
+ }
+
+ return value as T;
+ } catch (error) {
+ logger.error('Cache read error', {
+ key,
+ errorMessage: error instanceof Error ? error.message : 'Unknown error'
+ });
+ return null;
+ }
+}
+
+export function setCachedData(key: string, value: unknown): void {
+ try {
+ const item = {
+ value,
+ timestamp: Date.now(),
+ };
+ localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(item));
+ } catch (error) {
+ logger.error('Cache write error', {
+ key,
+ errorMessage: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+}
+
+export function clearCache(): void {
+ try {
+ Object.keys(localStorage)
+ .filter(key => key.startsWith(CACHE_PREFIX))
+ .forEach(key => localStorage.removeItem(key));
+ } catch (error) {
+ logger.error('Cache clear error', {
+ errorMessage: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/utils/contentGrouping.ts b/src/utils/contentGrouping.ts
new file mode 100644
index 0000000..c56fb34
--- /dev/null
+++ b/src/utils/contentGrouping.ts
@@ -0,0 +1,66 @@
+import { ContentItem } from '../types';
+
+export function getBaseTitleId(titleId: string): string {
+ // For base titles (ending in 000), return as is
+ if (titleId.endsWith('000')) {
+ return titleId;
+ }
+
+ // For updates (ending in 800), use the base title ID
+ if (titleId.endsWith('800')) {
+ return titleId.slice(0, -3) + '000';
+ }
+
+ // For DLCs, change the fourth-to-last digit and set last 3 digits to 000
+ const fourthFromEnd = titleId.charAt(titleId.length - 4);
+ const prevChar = (char: string): string => {
+ if (char >= '1' && char <= '9') return String(parseInt(char) - 1);
+ if (char >= 'b' && char <= 'z') return String.fromCharCode(char.charCodeAt(0) - 1);
+ if (char >= 'B' && char <= 'Z') return String.fromCharCode(char.charCodeAt(0) - 1);
+ return char;
+ };
+ return titleId.slice(0, -4) + prevChar(fourthFromEnd) + '000';
+}
+
+export function getBasePrefix(titleId: string): string {
+ return titleId.slice(0, 12);
+}
+
+export function getRelatedContent(items: ContentItem[], baseId: string): {
+ base: ContentItem | null;
+ updates: ContentItem[];
+ dlcs: ContentItem[];
+} {
+ // Get base prefix for finding related content
+ const basePrefix = getBasePrefix(baseId);
+
+ // Find all related items
+ const relatedItems = items.filter(item => getBasePrefix(item.id) === basePrefix);
+
+ // Get base game (should be the one matching baseId)
+ const base = relatedItems.find(item => item.id === baseId) || null;
+
+ // Get updates (ending with 800)
+ const updates = relatedItems.filter(item => item.id.endsWith('800'))
+ .sort((a, b) => (b.version || '0').localeCompare(a.version || '0'));
+
+ // Get DLCs (not base and not updates)
+ const dlcs = relatedItems.filter(item =>
+ !item.id.endsWith('000') && !item.id.endsWith('800')
+ ).sort((a, b) => a.id.localeCompare(b.id));
+
+ return { base, updates, dlcs };
+}
+
+export function getVisualAssets(titleId: string) {
+ const baseId = getBaseTitleId(titleId);
+ const baseUrl = 'https://api.nlib.cc/nx';
+
+ return {
+ icon: `${baseUrl}/${baseId}/icon/128/128`,
+ banner: `${baseUrl}/${baseId}/banner/1280/720`,
+ screenshots: Array.from({ length: 6 }, (_, i) =>
+ `${baseUrl}/${baseId}/screen/${i + 1}`
+ )
+ };
+}
\ No newline at end of file
diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts
new file mode 100644
index 0000000..7c1ec7c
--- /dev/null
+++ b/src/utils/formatters.ts
@@ -0,0 +1,37 @@
+import { getBaseTitleId } from './contentGrouping';
+
+export function getIconUrl(titleId: string): string {
+ return `https://api.nlib.cc/nx/${getBaseTitleId(titleId)}/icon/128/128`;
+}
+
+export function formatFileSize(bytes?: number): string {
+ if (!bytes) return 'Unknown';
+
+ const units = ['B', 'KB', 'MB', 'GB'];
+ let size = bytes;
+ let unitIndex = 0;
+
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex++;
+ }
+
+ return `${size.toFixed(2)} ${units[unitIndex]}`;
+}
+
+export function formatDate(dateString?: string): string {
+ if (!dateString) return 'Unknown';
+
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return 'Unknown';
+
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ }).format(date);
+ } catch {
+ return 'Unknown';
+ }
+}
\ No newline at end of file
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
new file mode 100644
index 0000000..23c8263
--- /dev/null
+++ b/src/utils/logger.ts
@@ -0,0 +1,70 @@
+export type LogLevel = 'info' | 'warn' | 'error';
+
+export interface LogDetails {
+ [key: string]: string | number | boolean | null | undefined;
+}
+
+export interface LogEntry {
+ timestamp: string;
+ level: LogLevel;
+ message: string;
+ details?: LogDetails;
+}
+
+class Logger {
+ private logs: LogEntry[] = [];
+ private readonly maxLogs = 1000;
+
+ private createEntry(level: LogLevel, message: string, details?: LogDetails): LogEntry {
+ return {
+ timestamp: new Date().toISOString(),
+ level,
+ message,
+ details,
+ };
+ }
+
+ private addLog(entry: LogEntry) {
+ this.logs.unshift(entry);
+ if (this.logs.length > this.maxLogs) {
+ this.logs.pop();
+ }
+
+ const style = `color: ${
+ entry.level === 'error' ? 'red' :
+ entry.level === 'warn' ? 'orange' :
+ 'blue'
+ }; font-weight: bold;`;
+
+ console.groupCollapsed(`%c${entry.level.toUpperCase()}: ${entry.message}`, style);
+ console.log('Timestamp:', entry.timestamp);
+ if (entry.details) console.log('Details:', entry.details);
+ console.groupEnd();
+ }
+
+ info(message: string, details?: LogDetails) {
+ this.addLog(this.createEntry('info', message, details));
+ }
+
+ warn(message: string, details?: LogDetails) {
+ this.addLog(this.createEntry('warn', message, details));
+ }
+
+ error(message: string, details?: LogDetails) {
+ this.addLog(this.createEntry('error', message, details));
+ }
+
+ getLogs(): LogEntry[] {
+ return [...this.logs];
+ }
+
+ getErrorLogs(): LogEntry[] {
+ return this.logs.filter(log => log.level === 'error');
+ }
+
+ clearLogs() {
+ this.logs = [];
+ }
+}
+
+export const logger = new Logger();
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..2f73add
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,24 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ background: 'rgb(var(--background) / )',
+ foreground: 'rgb(var(--foreground) / )',
+ muted: 'rgb(var(--muted) / )',
+ 'muted-foreground': 'rgb(var(--muted-foreground) / )',
+ card: 'rgb(var(--card) / )',
+ 'card-foreground': 'rgb(var(--card-foreground) / )',
+ border: 'rgb(var(--border) / )',
+ primary: 'rgb(var(--primary) / )',
+ 'primary-foreground': 'rgb(var(--primary-foreground) / )',
+ },
+ },
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7a7611e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
\ No newline at end of file
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..099658c
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..bfc3060
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: true,
+ host: true
+ }
+});
\ No newline at end of file