diff --git a/TODO.md b/TODO.md
index 3cb3b69..20be1bf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -138,72 +138,51 @@
- Document backend API requirements for live data
- **Note**: Deferred to Phase 5 after basic features are implemented
-## Phase 4 – Application Architecture & Routing
+## Phase 4 – Application Architecture & Routing (IN PROGRESS)
-- [ ] Create SvelteKit route structure in `src/routes/`:
- ```
- src/routes/
- ├── +layout.svelte # Root layout (header, footer, navigation)
- ├── +layout.ts # Root load function (user prefs, i18n)
- ├── +page.svelte # Homepage (/)
- ├── +page.ts # Homepage data (featured matches)
- ├── matches/
- │ ├── +page.svelte # Match listing (/matches)
- │ └── +page.ts # Load matches with filters
- ├── player/
- │ └── [id]/
- │ ├── +page.svelte # Player profile (/player/[id])
- │ └── +page.ts # Load player data
- └── match/
- └── [id]/
- ├── +layout.svelte # Match layout (tabs navigation)
- ├── +layout.ts # Load match data (shared by all tabs)
- ├── +page.svelte # Match overview (/match/[id])
- ├── economy/
- │ └── +page.svelte
- ├── details/
- │ └── +page.svelte
- ├── flashes/
- │ └── +page.svelte
- ├── damage/
- │ └── +page.svelte
- └── chat/
- └── +page.svelte
- ```
-- [ ] Implement root layout (`src/routes/+layout.svelte`):
- - Global header with logo, navigation, search bar
- - Language switcher component (dropdown with flag icons)
- - Theme toggle (light/dark/auto) with smooth transitions
- - Footer with links, version info, backend status indicator
- - Toast/notification system (top-right positioned)
- - Loading bar (NProgress-style) for route transitions
-- [ ] Create reusable layout components in `src/lib/components/layout/`:
- - `Header.svelte`: responsive navigation, mobile menu drawer
- - `Footer.svelte`: links, social, donation info
- - `SearchBar.svelte`: global search with keyboard shortcuts (Cmd+K)
- - `ThemeToggle.svelte`: theme switcher with icon animations
- - `LanguageSwitcher.svelte`: i18n selector with persistence
- - `Breadcrumbs.svelte`: contextual navigation breadcrumbs
-- [ ] Configure load functions with error handling:
- - Implement `+page.ts` load functions for data fetching
- - Add error boundaries: `+error.svelte` for each route level
- - Create loading skeletons: `+loading.svelte` (SvelteKit streaming)
- - Handle 404s, 500s, and API errors gracefully
- - Implement redirect logic for invalid match/player IDs
-- [ ] Set up state management with Svelte stores (`src/lib/stores/`):
- - `preferences.ts`: user settings (theme, language, units, favorites)
- - `search.ts`: search query, filters, recent searches
- - `cache.ts`: client-side data cache with TTL
- - `toast.ts`: notification queue and display logic
- - `auth.ts`: user authentication state (if auth is implemented)
- - Use `writable`, `derived`, and `readable` stores appropriately
- - Persist critical stores to localStorage with sync
+- [x] Create SvelteKit route structure in `src/routes/` (partial):
+ - ✅ Created: `+layout.svelte`, `+layout.ts`, `+error.svelte`
+ - ✅ Homepage: `+page.svelte`, `+page.ts` with featured matches loader
+ - ✅ Matches listing: `matches/+page.ts` with query params
+ - ✅ Players: `players/+page.ts` placeholder
+ - ✅ About: `about/+page.ts` static page
+ - ⚠️ Match detail routes (nested layouts): **TODO Phase 5**
+ - ⚠️ Player profile `[id]` route: **TODO Phase 5**
+- [x] Implement root layout (`src/routes/+layout.svelte`):
+ - ✅ Global header with logo and navigation (Header.svelte)
+ - ✅ Footer with links (Footer.svelte)
+ - ✅ Toast notification system (ToastContainer.svelte, top-right)
+ - ⚠️ Search bar with keyboard shortcuts: **TODO Phase 5**
+ - ⚠️ Language switcher: **TODO Phase 6 (Localization)**
+ - ⚠️ Theme toggle: **TODO Phase 5**
+ - ⚠️ Loading bar: **TODO Phase 5**
+- [x] Create reusable layout components in `src/lib/components/layout/` (partial):
+ - ✅ `Header.svelte`: responsive navigation, mobile menu drawer
+ - ✅ `Footer.svelte`: links, social, donation info
+ - ⚠️ `SearchBar.svelte`: **TODO Phase 5**
+ - ⚠️ `ThemeToggle.svelte`: **TODO Phase 5**
+ - ⚠️ `LanguageSwitcher.svelte`: **TODO Phase 6**
+ - ⚠️ `Breadcrumbs.svelte`: **TODO Phase 5**
+- [x] Configure load functions with error handling:
+ - ✅ Implemented `+page.ts` load functions (homepage, matches, players, about)
+ - ✅ Added error boundary: `+error.svelte` at root level (404, 500, 503 handling)
+ - ✅ API error handling in load functions (catch/fallback)
+ - ⚠️ Loading skeletons: **TODO Phase 5**
+ - ⚠️ Redirect logic for invalid IDs: **TODO Phase 5**
+- [x] Set up state management with Svelte stores (`src/lib/stores/`):
+ - ✅ `preferences.ts`: theme, language, favorites, advanced stats toggle, date format
+ - ✅ `search.ts`: search query, filters, recent searches (localStorage)
+ - ✅ `toast.ts`: notification queue with auto-dismiss, typed messages
+ - ✅ Used `writable` stores with custom methods
+ - ✅ localStorage persistence with browser environment detection
+ - ⚠️ `cache.ts`: **Deferred to Phase 7 (Performance)**
+ - ⚠️ `auth.ts`: **Not needed (no authentication planned)**
- [ ] Add analytics and privacy:
- - Choose privacy-respecting analytics (Plausible, Umami, or self-hosted)
- - Implement consent banner (GDPR-compliant)
- - Create analytics utility: `trackPageView()`, `trackEvent()`
- - Ensure SSR compatibility (client-side only execution)
- - Add opt-out mechanism
+ - ⚠️ Choose analytics solution: **TODO Phase 5**
+ - ⚠️ Implement consent banner: **TODO Phase 5**
+ - ⚠️ Create analytics utility: **TODO Phase 5**
+ - ⚠️ Ensure SSR compatibility: **TODO Phase 5**
+ - ⚠️ Add opt-out mechanism: **TODO Phase 5**
## Phase 5 – Feature Delivery (Parity + Enhancements)
diff --git a/src/lib/components/ui/Toast.svelte b/src/lib/components/ui/Toast.svelte
new file mode 100644
index 0000000..8d186a6
--- /dev/null
+++ b/src/lib/components/ui/Toast.svelte
@@ -0,0 +1,49 @@
+
+
+
+
+ {toast.message}
+
+ {#if toast.dismissible}
+
+ {/if}
+
diff --git a/src/lib/components/ui/ToastContainer.svelte b/src/lib/components/ui/ToastContainer.svelte
new file mode 100644
index 0000000..6aa561f
--- /dev/null
+++ b/src/lib/components/ui/ToastContainer.svelte
@@ -0,0 +1,11 @@
+
+
+
+
+ {#each $toast as toastItem (toastItem.id)}
+
+ {/each}
+
diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts
new file mode 100644
index 0000000..21fc761
--- /dev/null
+++ b/src/lib/stores/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Central export for all Svelte stores
+ */
+
+export { preferences } from './preferences';
+export type { UserPreferences } from './preferences';
+
+export { search, isSearchActive } from './search';
+export type { SearchState } from './search';
+
+export { toast } from './toast';
+export type { Toast } from './toast';
diff --git a/src/lib/stores/preferences.ts b/src/lib/stores/preferences.ts
new file mode 100644
index 0000000..1035dd1
--- /dev/null
+++ b/src/lib/stores/preferences.ts
@@ -0,0 +1,100 @@
+import { writable } from 'svelte/store';
+import { browser } from '$app/environment';
+
+/**
+ * User preferences store
+ * Persisted to localStorage
+ */
+
+export interface UserPreferences {
+ theme: 'cs2dark' | 'cs2light' | 'auto';
+ language: string;
+ favoriteMap?: string;
+ favoritePlayers: number[];
+ showAdvancedStats: boolean;
+ dateFormat: 'relative' | 'absolute';
+ timezone: string;
+}
+
+const defaultPreferences: UserPreferences = {
+ theme: 'cs2dark',
+ language: 'en',
+ favoritePlayers: [],
+ showAdvancedStats: false,
+ dateFormat: 'relative',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
+};
+
+// Load preferences from localStorage
+const loadPreferences = (): UserPreferences => {
+ if (!browser) return defaultPreferences;
+
+ try {
+ const stored = localStorage.getItem('cs2wtf-preferences');
+ if (stored) {
+ return { ...defaultPreferences, ...JSON.parse(stored) };
+ }
+ } catch (error) {
+ console.error('Failed to load preferences:', error);
+ }
+
+ return defaultPreferences;
+};
+
+// Create the store
+const createPreferencesStore = () => {
+ const { subscribe, set, update } = writable(loadPreferences());
+
+ return {
+ subscribe,
+ set: (value: UserPreferences) => {
+ if (browser) {
+ localStorage.setItem('cs2wtf-preferences', JSON.stringify(value));
+ }
+ set(value);
+ },
+ update: (fn: (value: UserPreferences) => UserPreferences) => {
+ update((current) => {
+ const newValue = fn(current);
+ if (browser) {
+ localStorage.setItem('cs2wtf-preferences', JSON.stringify(newValue));
+ }
+ return newValue;
+ });
+ },
+ reset: () => {
+ if (browser) {
+ localStorage.removeItem('cs2wtf-preferences');
+ }
+ set(defaultPreferences);
+ },
+
+ // Convenience methods
+ setTheme: (theme: UserPreferences['theme']) => {
+ update((prefs) => ({ ...prefs, theme }));
+ },
+ setLanguage: (language: string) => {
+ update((prefs) => ({ ...prefs, language }));
+ },
+ addFavoritePlayer: (playerId: number) => {
+ update((prefs) => ({
+ ...prefs,
+ favoritePlayers: [...new Set([...prefs.favoritePlayers, playerId])]
+ }));
+ },
+ removeFavoritePlayer: (playerId: number) => {
+ update((prefs) => ({
+ ...prefs,
+ favoritePlayers: prefs.favoritePlayers.filter((id) => id !== playerId)
+ }));
+ },
+ toggleAdvancedStats: () => {
+ update((prefs) => ({
+ ...prefs,
+ showAdvancedStats: !prefs.showAdvancedStats
+ }));
+ }
+ };
+};
+
+export const preferences = createPreferencesStore();
diff --git a/src/lib/stores/search.ts b/src/lib/stores/search.ts
new file mode 100644
index 0000000..a551d35
--- /dev/null
+++ b/src/lib/stores/search.ts
@@ -0,0 +1,118 @@
+import { writable, derived } from 'svelte/store';
+import { browser } from '$app/environment';
+
+/**
+ * Search state store
+ * Manages search queries and recent searches
+ */
+
+export interface SearchState {
+ query: string;
+ recentSearches: string[];
+ filters: {
+ map?: string;
+ playerId?: number;
+ dateFrom?: string;
+ dateTo?: string;
+ };
+}
+
+const defaultState: SearchState = {
+ query: '',
+ recentSearches: [],
+ filters: {}
+};
+
+// Load recent searches from localStorage
+const loadRecentSearches = (): string[] => {
+ if (!browser) return [];
+
+ try {
+ const stored = localStorage.getItem('cs2wtf-recent-searches');
+ if (stored) {
+ return JSON.parse(stored);
+ }
+ } catch (error) {
+ console.error('Failed to load recent searches:', error);
+ }
+
+ return [];
+};
+
+// Create the store
+const createSearchStore = () => {
+ const { subscribe, set, update } = writable({
+ ...defaultState,
+ recentSearches: loadRecentSearches()
+ });
+
+ return {
+ subscribe,
+ set,
+ update,
+
+ // Set search query
+ setQuery: (query: string) => {
+ update((state) => ({ ...state, query }));
+ },
+
+ // Clear search query
+ clearQuery: () => {
+ update((state) => ({ ...state, query: '' }));
+ },
+
+ // Add to recent searches (max 10)
+ addRecentSearch: (query: string) => {
+ if (!query.trim()) return;
+
+ update((state) => {
+ const recent = [query, ...state.recentSearches.filter((q) => q !== query)].slice(0, 10);
+
+ if (browser) {
+ localStorage.setItem('cs2wtf-recent-searches', JSON.stringify(recent));
+ }
+
+ return { ...state, recentSearches: recent };
+ });
+ },
+
+ // Clear recent searches
+ clearRecentSearches: () => {
+ if (browser) {
+ localStorage.removeItem('cs2wtf-recent-searches');
+ }
+ update((state) => ({ ...state, recentSearches: [] }));
+ },
+
+ // Set filters
+ setFilters: (filters: SearchState['filters']) => {
+ update((state) => ({ ...state, filters }));
+ },
+
+ // Update single filter
+ setFilter: (key: keyof SearchState['filters'], value: unknown) => {
+ update((state) => ({
+ ...state,
+ filters: { ...state.filters, [key]: value }
+ }));
+ },
+
+ // Clear filters
+ clearFilters: () => {
+ update((state) => ({ ...state, filters: {} }));
+ },
+
+ // Reset entire search state
+ reset: () => {
+ set({ ...defaultState, recentSearches: loadRecentSearches() });
+ }
+ };
+};
+
+export const search = createSearchStore();
+
+// Derived store: is search active?
+export const isSearchActive = derived(
+ search,
+ ($search) => $search.query.length > 0 || Object.keys($search.filters).length > 0
+);
diff --git a/src/lib/stores/toast.ts b/src/lib/stores/toast.ts
new file mode 100644
index 0000000..a558068
--- /dev/null
+++ b/src/lib/stores/toast.ts
@@ -0,0 +1,84 @@
+import { writable } from 'svelte/store';
+
+/**
+ * Toast notification store
+ * Manages temporary notifications to the user
+ */
+
+export interface Toast {
+ id: string;
+ message: string;
+ type: 'success' | 'error' | 'warning' | 'info';
+ duration?: number; // milliseconds, default 5000
+ dismissible?: boolean;
+}
+
+type ToastInput = Omit;
+
+const createToastStore = () => {
+ const { subscribe, update } = writable([]);
+
+ let nextId = 0;
+
+ const addToast = (toast: ToastInput) => {
+ const id = `toast-${++nextId}`;
+ const duration = toast.duration ?? 5000;
+ const dismissible = toast.dismissible ?? true;
+
+ const newToast: Toast = {
+ ...toast,
+ id,
+ duration,
+ dismissible
+ };
+
+ update((toasts) => [...toasts, newToast]);
+
+ // Auto-dismiss after duration
+ if (duration > 0) {
+ setTimeout(() => {
+ removeToast(id);
+ }, duration);
+ }
+
+ return id;
+ };
+
+ const removeToast = (id: string) => {
+ update((toasts) => toasts.filter((t) => t.id !== id));
+ };
+
+ return {
+ subscribe,
+
+ // Add toast with specific type
+ success: (message: string, duration?: number) => {
+ return addToast({ message, type: 'success', duration });
+ },
+
+ error: (message: string, duration?: number) => {
+ return addToast({ message, type: 'error', duration });
+ },
+
+ warning: (message: string, duration?: number) => {
+ return addToast({ message, type: 'warning', duration });
+ },
+
+ info: (message: string, duration?: number) => {
+ return addToast({ message, type: 'info', duration });
+ },
+
+ // Add custom toast
+ add: addToast,
+
+ // Remove specific toast
+ dismiss: removeToast,
+
+ // Clear all toasts
+ clear: () => {
+ update(() => []);
+ }
+ };
+};
+
+export const toast = createToastStore();
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..6168d4b
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,97 @@
+
+
+
+ {status} - {getErrorTitle(status)} | CS2.WTF
+
+
+
+
+
+
+
+ {status}
+
+
+
+
+ {getErrorTitle(status)}
+
+
+
+
+ {getErrorMessage(status)}
+
+
+
+ {#if import.meta.env?.DEV && error}
+
+
+ Debug Info:
+
+
{JSON.stringify(
+ error,
+ null,
+ 2
+ )}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ If this problem persists, please
+
+ report it on GitHub
+
+
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index ccefdef..488213c 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -2,6 +2,7 @@
import '../app.css';
import Header from '$lib/components/layout/Header.svelte';
import Footer from '$lib/components/layout/Footer.svelte';
+ import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
let { children } = $props();
@@ -12,4 +13,7 @@
{@render children()}
+
+
+
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
new file mode 100644
index 0000000..8e325a5
--- /dev/null
+++ b/src/routes/+layout.ts
@@ -0,0 +1,38 @@
+import type { LayoutLoad } from './$types';
+
+/**
+ * Root layout load function
+ * Runs on both server and client
+ */
+export const load: LayoutLoad = async () => {
+ // Load application-wide data here
+ // For now, just return empty data structure
+
+ return {
+ // App version from environment
+ appVersion: import.meta.env?.VITE_APP_VERSION || '2.0.0',
+
+ // Feature flags
+ features: {
+ liveMatches: import.meta.env?.VITE_ENABLE_LIVE_MATCHES === 'true',
+ analytics: import.meta.env?.VITE_ENABLE_ANALYTICS === 'true',
+ debugMode: import.meta.env?.VITE_DEBUG_MODE === 'true'
+ }
+ };
+};
+
+/**
+ * Prerender this layout (static generation)
+ * Set to false if you need dynamic data
+ */
+export const prerender = false;
+
+/**
+ * Enable client-side routing
+ */
+export const ssr = true;
+
+/**
+ * Trailing slash handling
+ */
+export const trailingSlash = 'never';
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index e9322da..b51ad65 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -3,34 +3,20 @@
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
+ import type { PageData } from './$types';
- // Demo data - will be replaced with real data
- const featuredMatches = [
- {
- id: '3589487716842078322',
- map: 'de_inferno',
- scoreT: 13,
- scoreCT: 10,
- date: '2 hours ago',
- live: false
- },
- {
- id: '3589487716842078323',
- map: 'de_mirage',
- scoreT: 11,
- scoreCT: 8,
- date: 'LIVE',
- live: true
- },
- {
- id: '3589487716842078324',
- map: 'de_dust2',
- scoreT: 16,
- scoreCT: 14,
- date: '5 hours ago',
- live: false
- }
- ];
+ // Get data from page loader
+ let { data }: { data: PageData } = $props();
+
+ // Transform API matches to display format
+ const featuredMatches = data.featuredMatches.map((match) => ({
+ id: match.match_id.toString(),
+ map: match.map,
+ scoreT: match.score_team_a,
+ scoreCT: match.score_team_b,
+ date: new Date(match.date).toLocaleString(),
+ live: false // TODO: Implement live match detection
+ }));
const stats = [
{ icon: Users, label: 'Players Tracked', value: '1.2M+' },
@@ -40,11 +26,8 @@
- CS2.WTF - Statistics for CS2 Matchmaking
-
+ {data.meta.title}
+
@@ -78,8 +61,9 @@
{#each stats as stat}
+ {@const StatIcon = stat.icon}
-
+
{stat.value}
{stat.label}
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
new file mode 100644
index 0000000..9f2282a
--- /dev/null
+++ b/src/routes/+page.ts
@@ -0,0 +1,38 @@
+import type { PageLoad } from './$types';
+import { api } from '$lib/api';
+
+/**
+ * Homepage data loader
+ * Loads featured matches for the homepage
+ */
+export const load: PageLoad = async ({ parent }) => {
+ // Wait for parent layout data
+ await parent();
+
+ try {
+ // Load featured matches (limit to 6 for homepage)
+ const matchesData = await api.matches.getMatches({ limit: 6 });
+
+ return {
+ featuredMatches: matchesData.matches,
+ meta: {
+ title: 'CS2.WTF - Statistics for CS2 Matchmaking',
+ description:
+ 'Track your CS2 performance, analyze matches, and improve your game with detailed statistics and insights.'
+ }
+ };
+ } catch (error) {
+ // Log error but don't fail the page load
+ console.error('Failed to load featured matches:', error);
+
+ // Return empty data - page will show without featured matches
+ return {
+ featuredMatches: [],
+ meta: {
+ title: 'CS2.WTF - Statistics for CS2 Matchmaking',
+ description:
+ 'Track your CS2 performance, analyze matches, and improve your game with detailed statistics and insights.'
+ }
+ };
+ }
+};
diff --git a/src/routes/about/+page.ts b/src/routes/about/+page.ts
new file mode 100644
index 0000000..1060d46
--- /dev/null
+++ b/src/routes/about/+page.ts
@@ -0,0 +1,14 @@
+import type { PageLoad } from './$types';
+
+/**
+ * About page data loader
+ */
+export const load: PageLoad = async () => {
+ return {
+ meta: {
+ title: 'About CS2.WTF',
+ description:
+ 'Learn about CS2.WTF, an open-source platform for analyzing Counter-Strike 2 matchmaking matches.'
+ }
+ };
+};
diff --git a/src/routes/matches/+page.ts b/src/routes/matches/+page.ts
new file mode 100644
index 0000000..2523985
--- /dev/null
+++ b/src/routes/matches/+page.ts
@@ -0,0 +1,49 @@
+import type { PageLoad } from './$types';
+import { api } from '$lib/api';
+
+/**
+ * Matches listing page data loader
+ */
+export const load: PageLoad = async ({ url }) => {
+ // Get query parameters
+ const map = url.searchParams.get('map') || undefined;
+ const playerIdStr = url.searchParams.get('player_id');
+ const playerId = playerIdStr ? Number(playerIdStr) : undefined;
+ const limit = Number(url.searchParams.get('limit')) || 50;
+
+ try {
+ // Load matches with filters
+ const matchesData = await api.matches.getMatches({
+ limit,
+ map,
+ player_id: playerId
+ });
+
+ return {
+ matches: matchesData.matches,
+ hasMore: matchesData.has_more,
+ nextPageTime: matchesData.next_page_time,
+ filters: {
+ map,
+ playerId
+ },
+ meta: {
+ title: 'Browse Matches - CS2.WTF',
+ description: 'Browse and search through CS2 matchmaking games with detailed filters.'
+ }
+ };
+ } catch (error) {
+ console.error('Failed to load matches:', error);
+
+ // Return empty state on error
+ return {
+ matches: [],
+ hasMore: false,
+ filters: { map, playerId },
+ meta: {
+ title: 'Browse Matches - CS2.WTF',
+ description: 'Browse and search through CS2 matchmaking games with detailed filters.'
+ }
+ };
+ }
+};
diff --git a/src/routes/players/+page.ts b/src/routes/players/+page.ts
new file mode 100644
index 0000000..c34a629
--- /dev/null
+++ b/src/routes/players/+page.ts
@@ -0,0 +1,14 @@
+import type { PageLoad } from './$types';
+
+/**
+ * Players page data loader
+ * Currently placeholder - will be implemented in Phase 3
+ */
+export const load: PageLoad = async () => {
+ return {
+ meta: {
+ title: 'Search Players - CS2.WTF',
+ description: 'Search and browse CS2 player profiles with detailed statistics.'
+ }
+ };
+};