Merge pull request #7 from darkraise/feature/ui-enhancement

Feature/UI enhancement
This commit is contained in:
Darkraise
2026-02-13 02:08:02 +07:00
committed by GitHub
56 changed files with 1358 additions and 376 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import path from 'path';
import fs from 'fs/promises';
import { logger } from '../utils/logger';
import { zipManager } from './zip-manager';
import { ConfigManager, MAX_BUFFERED_FILE_SIZE } from './config';
import { ConfigManager } from './config';
import { getMimeType } from './mimeTypes';
import { injectPolyfills } from './utils/htmlInjector';
import { setCorsHeaders } from './utils/cors';
@@ -8,7 +8,7 @@ import net from 'net';
import dns from 'dns';
import { promisify } from 'util';
import { logger } from '../../utils/logger';
import { PreferencesService, GameDataSource } from '../../services/PreferencesService';
import { PreferencesService } from '../../services/PreferencesService';
const dnsLookup = promisify(dns.lookup);
-1
View File
@@ -1,5 +1,4 @@
import StreamZip from 'node-stream-zip';
import path from 'path';
import fs from 'fs/promises';
import { LRUCache } from 'lru-cache';
import { logger } from '../utils/logger';
@@ -43,7 +43,6 @@ CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
description TEXT,
priority INTEGER DEFAULT 0,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
@@ -355,10 +354,10 @@ END;
-- PART 3: SEED DATA - ROLES
-- ============================================================================
INSERT OR IGNORE INTO roles (id, name, description, priority) VALUES
(1, 'admin', 'Administrator with full access to all features', 100),
(2, 'user', 'Regular user with standard access', 50),
(3, 'guest', 'Guest user with read-only access', 0);
INSERT OR IGNORE INTO roles (id, name, description) VALUES
(1, 'admin', 'Administrator with full access to all features'),
(2, 'user', 'Regular user with standard access'),
(3, 'guest', 'Guest user with read-only access');
-- ============================================================================
-- PART 4: SEED DATA - PERMISSIONS
-1
View File
@@ -1,5 +1,4 @@
import { Router, Request, Response } from 'express';
import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import winston from 'winston';
-1
View File
@@ -1,7 +1,6 @@
import { Router, Request, Response } from 'express';
import { IncomingMessage, ServerResponse } from 'http';
import { ProxyRequestHandler } from '../game/proxy-request-handler';
import { logger } from '../utils/logger';
import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
+16 -2
View File
@@ -7,7 +7,6 @@ import { asyncHandler } from '../middleware/asyncHandler';
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
import { logActivity } from '../middleware/activityLogger';
import { rateLimitStandard } from '../middleware/rateLimiter';
import { logger } from '../utils/logger';
import { z } from 'zod';
const router = Router();
@@ -132,8 +131,23 @@ router.get(
'/random',
asyncHandler(async (req, res) => {
const librarySchema = z.enum(['arcade', 'theatre']).optional();
const library = librarySchema.parse(req.query.library);
const game = await gameService.getRandomGame(library);
// Parse platforms - can be comma-separated string or array
let platforms: string[] | undefined;
if (req.query.platforms) {
const raw = req.query.platforms;
if (typeof raw === 'string') {
platforms = raw.split(',').map((p) => p.trim()).filter(Boolean);
} else if (Array.isArray(raw)) {
platforms = (raw as string[])
.filter((p) => typeof p === 'string')
.map((p) => p.trim())
.filter(Boolean);
}
}
const game = await gameService.getRandomGame(library, platforms);
if (!game) {
throw new AppError(404, 'No games found');
-1
View File
@@ -3,7 +3,6 @@ import { PlayTrackingService } from '../services/PlayTrackingService';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { requireFeature } from '../middleware/featureFlags';
import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { logActivity } from '../middleware/activityLogger';
import { z } from 'zod';
+1 -5
View File
@@ -1,9 +1,5 @@
import { Router } from 'express';
import {
PlaylistService,
CreatePlaylistDto,
AddGamesToPlaylistDto,
} from '../services/PlaylistService';
import { PlaylistService } from '../services/PlaylistService';
import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { authenticate, optionalAuth } from '../middleware/auth';
+1 -4
View File
@@ -13,14 +13,12 @@ const roleService = new RoleService();
const createRoleSchema = z.object({
name: z.string().min(3).max(50),
description: z.string().optional(),
priority: z.number().int().default(0),
permissionIds: z.array(z.number().int().positive()).optional(),
});
const updateRoleSchema = z.object({
name: z.string().min(3).max(50).optional(),
description: z.string().optional(),
priority: z.number().int().optional(),
});
const updatePermissionsSchema = z.object({
@@ -87,7 +85,6 @@ router.post(
const role = await roleService.createRole(
data.name,
data.description,
data.priority,
data.permissionIds || []
);
@@ -108,7 +105,7 @@ router.patch(
const data = updateRoleSchema.parse(req.body);
const role = await roleService.updateRole(id, data.name, data.description, data.priority);
const role = await roleService.updateRole(id, data.name, data.description);
res.json(role);
})
+1 -2
View File
@@ -46,10 +46,9 @@ export class AuthService {
email: string;
password_hash: string;
role_name: string;
priority: number;
}>(
`SELECT u.id, u.username, u.email, u.password_hash,
r.name as role_name, r.priority
r.name as role_name
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.username = ? AND u.is_active = 1`,
-1
View File
@@ -35,7 +35,6 @@ type DetailsCallback = (details: DownloadDetails) => void;
export class DownloadManager {
private static readonly TEMP_DIR = path.join(process.cwd(), 'backend', 'temp-downloads');
private static readonly DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes per source
private static readonly MAX_RETRIES = 3;
private static readonly MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
// Track active downloads to prevent duplicates
+7 -1
View File
@@ -612,7 +612,7 @@ export class GameService {
}
}
async getRandomGame(library?: string): Promise<Game | null> {
async getRandomGame(library?: string, platforms?: string[]): Promise<Game | null> {
try {
let sql = `
SELECT
@@ -631,6 +631,12 @@ export class GameService {
params.push(library);
}
if (platforms && platforms.length > 0) {
const placeholders = platforms.map(() => '?').join(', ');
sql += ` AND g.platformName IN (${placeholders})`;
params.push(...platforms);
}
sql += ` ORDER BY RANDOM() LIMIT 1`;
const game = DatabaseService.get(sql, params) as Game | null;
@@ -474,7 +474,6 @@ export class MetadataSyncService {
* Chunks batches to stay under SQLite's SQLITE_MAX_VARIABLE_NUMBER (default 999).
* With 29 columns per game, max ~30 games per chunk (30 * 29 = 870 < 999).
*/
private static readonly COLUMNS_PER_GAME = 29;
private static readonly MAX_GAMES_PER_CHUNK = 30; // 30 * 29 = 870 params, under 999 limit
private async applyGames(games: ApiGame[]): Promise<void> {
@@ -1,5 +1,4 @@
import { UserDatabaseService } from './UserDatabaseService';
import { DatabaseService } from './DatabaseService';
import { logger } from '../utils/logger';
import { randomUUID } from 'crypto';
import { AppError } from '../middleware/errorHandler';
+6 -21
View File
@@ -16,13 +16,12 @@ export class RoleService {
id: number;
name: string;
description: string | null;
priority: number;
createdAt: string;
updatedAt: string;
}>(
`SELECT id, name, description, priority, created_at as createdAt, updated_at as updatedAt
`SELECT id, name, description, created_at as createdAt, updated_at as updatedAt
FROM roles
ORDER BY priority DESC, name ASC`,
ORDER BY name ASC`,
[]
);
@@ -61,7 +60,6 @@ export class RoleService {
id: role.id,
name: role.name,
description: role.description ?? undefined,
priority: role.priority,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
permissions: permissionsByRole.get(role.id) || [],
@@ -73,11 +71,10 @@ export class RoleService {
id: number;
name: string;
description: string | null;
priority: number;
createdAt: string;
updatedAt: string;
}>(
`SELECT id, name, description, priority, created_at as createdAt, updated_at as updatedAt
`SELECT id, name, description, created_at as createdAt, updated_at as updatedAt
FROM roles WHERE id = ?`,
[id]
);
@@ -88,7 +85,6 @@ export class RoleService {
id: role.id,
name: role.name,
description: role.description ?? undefined,
priority: role.priority,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
permissions: this.getRolePermissions(id),
@@ -140,7 +136,6 @@ export class RoleService {
async createRole(
name: string,
description?: string,
priority: number = 0,
permissionIds: number[] = []
): Promise<Role> {
// Wrap in transaction to prevent TOCTOU race on name uniqueness
@@ -152,8 +147,8 @@ export class RoleService {
}
const result = db
.prepare('INSERT INTO roles (name, description, priority) VALUES (?, ?, ?)')
.run(name, description, priority);
.prepare('INSERT INTO roles (name, description) VALUES (?, ?)')
.run(name, description);
return result.lastInsertRowid as number;
})();
@@ -166,12 +161,7 @@ export class RoleService {
return (await this.getRoleById(roleId))!;
}
async updateRole(
id: number,
name?: string,
description?: string,
priority?: number
): Promise<Role> {
async updateRole(id: number, name?: string, description?: string): Promise<Role> {
const role = await this.getRoleById(id);
if (!role) {
throw new AppError(404, 'Role not found');
@@ -195,11 +185,6 @@ export class RoleService {
params.push(description);
}
if (priority !== undefined) {
updates.push('priority = ?');
params.push(priority);
}
if (updates.length === 0) {
return role;
}
+2 -2
View File
@@ -74,8 +74,8 @@ export function createTestRole(
permissions: string[] = []
) {
const result = db
.prepare(`INSERT INTO roles (name, description, priority) VALUES (?, ?, ?)`)
.run(name, `Test role: ${name}`, 50);
.prepare(`INSERT INTO roles (name, description) VALUES (?, ?)`)
.run(name, `Test role: ${name}`);
const roleId = result.lastInsertRowid as number;
-1
View File
@@ -81,7 +81,6 @@ export interface Role {
id: number;
name: string;
description?: string;
priority: number;
permissions: Permission[];
createdAt: string;
updatedAt: string;
-1
View File
@@ -132,7 +132,6 @@ export interface RoleRow {
id: number;
name: string;
description: string | null;
priority: number;
created_at: string;
updated_at: string;
}
+31 -13
View File
@@ -1,14 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="redGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E63939"/>
<stop offset="100%" style="stop-color:#C92A2A"/>
</linearGradient>
</defs>
<!-- Main arrow shape -->
<path fill="url(#redGradient)" d="M480 256L96 480V352L288 256L96 160V32L480 256Z"/>
<!-- Speed lines -->
<path fill="url(#redGradient)" d="M32 128L128 160L32 192V128Z"/>
<path fill="url(#redGradient)" d="M32 224L160 256L32 288V224Z"/>
<path fill="url(#redGradient)" d="M32 320L128 352L32 384V320Z"/>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="70" height="70" version="1.1" viewBox="0 0 18.521 18.521" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="b" x1="-9.0872" x2="53.183" y1="188.36" y2="250.9" gradientUnits="userSpaceOnUse">
<stop stop-color="#e81c24" offset="0"/>
<stop stop-color="#7c1315" offset="1"/>
</linearGradient>
<linearGradient id="a" x1="-464.96" x2="-248.84" y1="308.81" y2="308.81" gradientUnits="userSpaceOnUse">
<stop stop-color="#e5e5e5" offset="0"/>
<stop stop-color="#fefefe" offset="1"/>
</linearGradient>
<linearGradient id="c" x1="-464.96" x2="-248.84" y1="308.81" y2="308.81" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
<clipPath id="d">
<rect x="3.9451" y="198.8" width="41.158" height="41.158" rx="2.2086e-15" fill="#0ff" fill-rule="evenodd" opacity=".5" stroke-linecap="square" stroke-width=".91439"/>
</clipPath>
</defs>
<g transform="matrix(.45 0 0 .45 -1.7753 -89.458)" clip-path="url(#d)" stroke-width="1.2">
<rect x="-17.105" y="179.67" width="79.375" height="79.375" fill="url(#b)"/>
<g transform="matrix(.30971 0 0 .30971 135.2 124.53)" fill="#f00">
<path d="m-403.24 365.59 134.87-41.564-180.39-104.72 140.29 103.62-85.28 24.963z"/>
<path d="m-458.86 226.31 63.57 79.08-45.883 92.936 30.169-9.2976c12.024-27.645 24.048-55.29 36.072-82.934l1.5684 21.762 37.364-9.6537z"/>
</g>
<g transform="matrix(.30971 0 0 .30971 135.2 122.91)" fill="#800000">
<path d="m-403.24 365.59 134.87-41.564-180.39-104.72 140.29 103.62-85.28 24.963z"/>
<path d="m-458.86 226.31 63.57 79.08-45.883 92.936 30.169-9.2976c12.024-27.645 24.048-55.29 36.072-82.934l1.5684 21.762 37.364-9.6537z"/>
</g>
<g transform="matrix(.30971 0 0 .30971 135.2 123.72)" fill="url(#c)" stroke-width="3.8746">
<path d="m-403.24 365.59 134.87-41.564-180.39-104.72 140.29 103.62-85.28 24.963z" stroke-width="3.8746"/>
<path d="m-458.86 226.31 63.57 79.08-45.883 92.936 30.169-9.2976c12.024-27.645 24.048-55.29 36.072-82.934l1.5684 21.762 37.364-9.6537z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 2.0 KiB

+86 -2
View File
@@ -278,7 +278,7 @@ function App() {
{/* Public/guest accessible routes */}
<Route
path="/flash-games"
path="/flash"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
@@ -288,7 +288,7 @@ function App() {
}
/>
<Route
path="/html5-games"
path="/html5"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
@@ -317,6 +317,90 @@ function App() {
</ProtectedRoute>
}
/>
{/* Section-based game routes - must come before /games/:id */}
<Route
path="/flash/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/flash/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/animations/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/animations/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/browse/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/browse/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
</Suspense>
</ProtectedRoute>
}
/>
{/* Legacy game routes - fallback for shared playlists and backward compatibility */}
<Route
path="/games/:id"
element={
+77 -38
View File
@@ -1,10 +1,14 @@
import { Link, useNavigate } from 'react-router-dom';
import { ChevronRight, Home, ArrowLeft } from 'lucide-react';
import { ChevronRight, ArrowLeft, Film, Gamepad2, List, ListVideo, Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SectionIcon } from '@/lib/sectionRoutes';
export interface BreadcrumbContext {
label: string;
href: string;
icon?: SectionIcon;
/** Optional parent breadcrumb for nested navigation (e.g., Flashpoint Playlists > Playlist Name) */
parent?: BreadcrumbContext;
}
export interface PlayerBreadcrumbContext {
@@ -20,90 +24,125 @@ export interface BreadcrumbItem {
label: string;
href?: string;
active?: boolean;
icon?: SectionIcon;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
showHome?: boolean;
homeLabel?: string;
homeHref?: string;
showBackButton?: boolean;
className?: string;
/** Fallback href when back button has nowhere to go */
fallbackHref?: string;
}
/**
* Render icon for a breadcrumb item
*/
function BreadcrumbIcon({ icon }: { icon: SectionIcon }) {
if (icon.type === 'image') {
return (
<img
src={icon.value}
alt=""
aria-hidden="true"
className="w-4 h-4 object-contain"
/>
);
}
// Lucide icons
switch (icon.value) {
case 'Film':
return <Film size={14} aria-hidden="true" />;
case 'Gamepad2':
return <Gamepad2 size={14} aria-hidden="true" />;
case 'List':
return <List size={14} aria-hidden="true" />;
case 'ListVideo':
return <ListVideo size={14} aria-hidden="true" />;
case 'Heart':
return <Heart size={14} aria-hidden="true" />;
default:
return null;
}
}
export function Breadcrumbs({
items,
showHome = true,
homeLabel = 'Home',
homeHref = '/',
showBackButton = true,
className,
fallbackHref = '/',
}: BreadcrumbsProps) {
const navigate = useNavigate();
const handleBack = () => {
// Check if there's meaningful browser history to go back to
// history.length > 2 because: 1 = initial page, 2 = current page after one navigation
if (window.history.length > 2) {
navigate(-1);
return;
}
// Fallback: navigate to previous breadcrumb item
const previousItem = items.length >= 2 ? items[items.length - 2] : null;
if (previousItem?.href) {
navigate(previousItem.href);
return;
}
// Final fallback: go to provided fallback or home
navigate(fallbackHref);
};
return (
<nav
aria-label="Breadcrumb"
className={cn('flex items-center gap-2 text-sm rounded-lg bg-muted/50 px-3 py-2', className)}
className={cn(
'flex items-center gap-2 text-sm rounded-lg',
'sticky top-0 z-30 bg-background/40 backdrop-blur-md border border-primary/50 shadow-md drop-shadow-lg',
className
)}
>
{showBackButton ? (
<>
<button
onClick={() => navigate(-1)}
className="rounded-md p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onClick={handleBack}
className="rounded-md py-2 px-4 text-muted-foreground hover:text-foreground hover:bg-primary/20 transition-colors ml-1"
aria-label="Go back"
>
<ArrowLeft size={16} />
<ArrowLeft size={18} aria-hidden="true" />
</button>
<div className="w-px h-5 bg-border" aria-hidden="true" />
<div className="w-px h-6 bg-border my-1.5" aria-hidden="true" />
</>
) : null}
<ol className="flex items-center gap-1 flex-wrap">
{showHome ? (
<>
<li>
<Link
to={homeHref}
className="flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
aria-label={homeLabel}
>
<Home size={14} />
<span className="hidden sm:inline">{homeLabel}</span>
</Link>
</li>
{items.length > 0 ? (
<li aria-hidden="true" className="flex items-center">
<ChevronRight size={14} className="text-muted-foreground" />
</li>
) : null}
</>
) : null}
<ol className={cn('flex items-center gap-1 flex-wrap my-2 mr-3', !showBackButton && 'ml-3')}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
const isActive = item.active ?? isLast;
return (
<li key={item.href ?? item.label} className="flex items-center gap-1">
<li key={`${index}-${item.href ?? item.label}`} className="flex items-center gap-1">
{item.href && !isActive ? (
<Link
to={item.href}
className="rounded-md px-2 py-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors max-w-[200px] truncate"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors max-w-[200px]"
title={item.label}
>
{item.label}
{item.icon ? <BreadcrumbIcon icon={item.icon} /> : null}
<span className="truncate">{item.label}</span>
</Link>
) : (
<span
className={cn(
'rounded-md px-2 py-1 max-w-[200px] truncate',
'flex items-center gap-1.5 rounded-md px-2 py-1 max-w-[200px]',
isActive ? 'text-foreground font-medium' : 'text-muted-foreground'
)}
aria-current={isActive ? 'page' : undefined}
title={item.label}
>
{item.label}
{item.icon ? <BreadcrumbIcon icon={item.icon} /> : null}
<span className="truncate">{item.label}</span>
</span>
)}
@@ -86,6 +86,7 @@ export function HomeSection({
showAddToPlaylistButton={true}
favoriteGameIds={favoriteGameIds}
breadcrumbContext={{ label: 'Home', href: '/' }}
sectionKey="browse"
/>
</div>
))}
+21 -18
View File
@@ -22,10 +22,10 @@ export function AppShell({ children }: AppShellProps) {
const handleFeelingLucky = async () => {
setIsNavigating(true);
try {
const game = await gamesApi.getRandom();
navigate(`/games/${game.id}`, {
state: { breadcrumbContext: { label: 'Home', href: '/' } },
});
// Only show Flash and HTML5 games (web-playable platforms)
const game = await gamesApi.getRandom(undefined, ['Flash', 'HTML5']);
// Navigate to browse section - context derived from URL path
navigate(`/browse/${game.id}`);
} catch (error) {
logger.error('Failed to fetch random game:', error);
toast.error('Failed to load a random game. Please try again.');
@@ -61,20 +61,23 @@ export function AppShell({ children }: AppShellProps) {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={handleFeelingLucky}
disabled={isNavigating}
className="fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg hover:shadow-xl transition-shadow"
aria-label="I'm Feeling Lucky — open a random game"
>
{isNavigating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Shuffle className="h-5 w-5" />
)}
</Button>
<div className="fixed bottom-6 right-6 z-50 animate-bounce">
<span className="absolute inset-0 h-12 w-12 rounded-full bg-primary animate-ping opacity-25" />
<Button
variant="default"
size="icon"
onClick={handleFeelingLucky}
disabled={isNavigating}
className="relative group h-12 w-12 rounded-full shadow-lg hover:shadow-xl hover:scale-110 active:scale-95 transition-all duration-200"
aria-label="I'm Feeling Lucky — open a random game"
>
{isNavigating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Shuffle className="h-5 w-5 group-hover:rotate-180 transition-transform duration-300" />
)}
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="left">I'm Feeling Lucky</TooltipContent>
</Tooltip>
+2 -2
View File
@@ -69,8 +69,8 @@ export function Sidebar({ isOpen }: SidebarProps) {
const gameNavItems: NavItem[] = [
{ path: '/', icon: Home, label: 'Home' },
{ path: '/flash-games', iconImage: '/images/Flash.png', label: 'Flash Games' },
{ path: '/html5-games', iconImage: '/images/HTML5.png', label: 'HTML5 Games' },
{ path: '/flash', iconImage: '/images/Flash.png', label: 'Flash Games' },
{ path: '/html5', iconImage: '/images/HTML5.png', label: 'HTML5 Games' },
{ path: '/animations', icon: Film, label: 'Animations' },
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
];
+39 -2
View File
@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SIDEBAR_PREFIX_MAP } from '@/lib/sectionRoutes';
interface SidebarItemProps {
path: string;
@@ -27,11 +28,47 @@ export function SidebarItem({
const itemPath = path.split('?')[0];
const itemQuery = path.split('?')[1];
// Exact match for the path
if (itemQuery) {
return location.pathname === itemPath && location.search === `?${itemQuery}`;
if (location.pathname === itemPath && location.search === `?${itemQuery}`) {
return true;
}
} else if (location.pathname === itemPath) {
return true;
}
return location.pathname === itemPath;
// Check breadcrumb context from location state
// This takes priority over prefix matching to handle cases like
// opening a game from Flashpoint Playlists (should highlight Playlists, not Browse)
const state = location.state as {
breadcrumbContext?: { href?: string; parent?: { href?: string } };
playerBreadcrumbContext?: { breadcrumbContext?: { href?: string; parent?: { href?: string } } };
} | null;
const breadcrumbContext =
state?.breadcrumbContext ?? state?.playerBreadcrumbContext?.breadcrumbContext;
// If breadcrumb context exists, use it exclusively for matching
if (breadcrumbContext) {
// Check direct breadcrumb href
if (breadcrumbContext.href === itemPath) {
return true;
}
// Check parent breadcrumb href (e.g., Flashpoint Playlists when viewing a game from a playlist)
if (breadcrumbContext.parent?.href === itemPath) {
return true;
}
// Breadcrumb context exists but doesn't match this item
return false;
}
// No breadcrumb context - fall back to prefix matching for direct URL access
const prefixes = SIDEBAR_PREFIX_MAP[itemPath];
if (prefixes?.some((prefix) => location.pathname.startsWith(prefix))) {
return true;
}
return false;
})();
return (
@@ -22,6 +22,8 @@ interface GameBrowseLayoutProps {
platform?: string;
headerContent?: ReactNode;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
sectionKey?: string | null;
}
export function GameBrowseLayout({
@@ -30,6 +32,7 @@ export function GameBrowseLayout({
platform,
headerContent,
breadcrumbContext,
sectionKey = null,
}: GameBrowseLayoutProps) {
const [searchParams, setSearchParams] = useSearchParams();
const viewMode = useUIStore((state) => state.viewMode);
@@ -319,12 +322,14 @@ export function GameBrowseLayout({
games={data.data}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={breadcrumbContext}
sectionKey={sectionKey}
/>
) : (
<GameList
games={data.data}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={breadcrumbContext}
sectionKey={sectionKey}
/>
)}
+12 -3
View File
@@ -14,6 +14,7 @@ import { useAuthStore } from '@/store/auth';
import { toast } from 'sonner';
import { buildSharedGameUrl } from '@/hooks/useSharedPlaylistAccess';
import { BreadcrumbContext } from '@/components/common/Breadcrumbs';
import { buildSectionGameUrl } from '@/lib/sectionRoutes';
interface GameCardProps {
game: Game;
@@ -25,6 +26,8 @@ interface GameCardProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
sectionKey?: string | null;
}
const GameCardComponent = function GameCard({
@@ -37,6 +40,7 @@ const GameCardComponent = function GameCard({
isFavoritePage = false,
shareToken = null,
breadcrumbContext,
sectionKey = null,
}: GameCardProps) {
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
@@ -50,8 +54,12 @@ const GameCardComponent = function GameCard({
const isFavorited = favoriteGameIds?.has(game.id) ?? false;
const isPlayable = game.platformName === 'Flash' || game.platformName === 'HTML5';
const gameDetailUrl = buildSharedGameUrl(`/games/${game.id}`, shareToken);
const gamePlayUrl = buildSharedGameUrl(`/games/${game.id}/play`, shareToken);
const gameDetailUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: buildSectionGameUrl(game.id, sectionKey);
const gamePlayUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: buildSectionGameUrl(game.id, sectionKey, true);
const handleCardClick = (e?: React.MouseEvent | React.KeyboardEvent) => {
if (e && 'key' in e && e.key !== 'Enter' && e.key !== ' ') {
@@ -275,7 +283,8 @@ export const GameCard = memo(GameCardComponent, (prevProps, nextProps) => {
prevProps.isFavoritePage !== nextProps.isFavoritePage ||
prevProps.shareToken !== nextProps.shareToken ||
prevProps.breadcrumbContext?.label !== nextProps.breadcrumbContext?.label ||
prevProps.breadcrumbContext?.href !== nextProps.breadcrumbContext?.href
prevProps.breadcrumbContext?.href !== nextProps.breadcrumbContext?.href ||
prevProps.sectionKey !== nextProps.sectionKey
) {
return false;
}
@@ -11,6 +11,8 @@ interface GameGridProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
sectionKey?: string | null;
}
export function GameGrid({
@@ -21,6 +23,7 @@ export function GameGrid({
isFavoritePage = false,
shareToken = null,
breadcrumbContext,
sectionKey = null,
}: GameGridProps) {
const cardSize = useUIStore((state) => state.cardSize);
@@ -51,6 +54,7 @@ export function GameGrid({
isFavoritePage={isFavoritePage}
shareToken={shareToken}
breadcrumbContext={breadcrumbContext}
sectionKey={sectionKey}
/>
))}
</div>
@@ -11,6 +11,8 @@ interface GameListProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
sectionKey?: string | null;
}
export function GameList({
@@ -21,6 +23,7 @@ export function GameList({
isFavoritePage = false,
shareToken = null,
breadcrumbContext,
sectionKey = null,
}: GameListProps) {
const listColumns = useUIStore((state) => state.listColumns);
@@ -49,6 +52,7 @@ export function GameList({
isFavoritePage={isFavoritePage}
shareToken={shareToken}
breadcrumbContext={breadcrumbContext}
sectionKey={sectionKey}
/>
))}
</div>
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
import { buildSharedGameUrl } from '@/hooks/useSharedPlaylistAccess';
import { BreadcrumbContext } from '@/components/common/Breadcrumbs';
import { getGameLogoUrl } from '@/utils/gameUtils';
import { buildSectionGameUrl } from '@/lib/sectionRoutes';
interface GameListItemProps {
game: Game;
@@ -24,6 +25,8 @@ interface GameListItemProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
sectionKey?: string | null;
}
export const GameListItem = memo(
@@ -37,6 +40,7 @@ export const GameListItem = memo(
isFavoritePage = false,
shareToken = null,
breadcrumbContext,
sectionKey = null,
}: GameListItemProps) {
const [imageError, setImageError] = useState(false);
const [isPlaylistModalOpen, setIsPlaylistModalOpen] = useState(false);
@@ -57,8 +61,12 @@ export const GameListItem = memo(
const isFavorited = favoriteGameIds?.has(game.id) ?? false;
const isPlayable = game.platformName === 'Flash' || game.platformName === 'HTML5';
const gameDetailUrl = buildSharedGameUrl(`/games/${game.id}`, shareToken);
const gamePlayUrl = buildSharedGameUrl(`/games/${game.id}/play`, shareToken);
const gameDetailUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: buildSectionGameUrl(game.id, sectionKey);
const gamePlayUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: buildSectionGameUrl(game.id, sectionKey, true);
const handleNavigateToDetails = () => {
navigate(gameDetailUrl, {
@@ -196,6 +204,7 @@ export const GameListItem = memo(
prevProps.shareToken === nextProps.shareToken &&
prevProps.breadcrumbContext?.label === nextProps.breadcrumbContext?.label &&
prevProps.breadcrumbContext?.href === nextProps.breadcrumbContext?.href &&
prevProps.sectionKey === nextProps.sectionKey &&
(prevProps.favoriteGameIds?.has(prevProps.game.id) ?? false) ===
(nextProps.favoriteGameIds?.has(nextProps.game.id) ?? false)
);
@@ -34,10 +34,6 @@ const roleSchema = z.object({
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name must be at most 50 characters'),
description: z.string().optional(),
priority: z
.number()
.min(0, 'Priority must be at least 0')
.max(100, 'Priority must be at most 100'),
permissionIds: z.array(z.number()).optional(),
});
@@ -61,7 +57,6 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
defaultValues: {
name: role?.name ?? '',
description: role?.description ?? '',
priority: role?.priority ?? 0,
permissionIds: role?.permissions?.map((p) => p.id) ?? [],
},
});
@@ -71,7 +66,6 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
form.reset({
name: role.name,
description: role.description,
priority: role.priority,
permissionIds: role.permissions?.map((p) => p.id) || [],
});
}
@@ -83,14 +77,12 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
const updateData: UpdateRoleData = {
name: values.name,
description: values.description,
priority: values.priority,
};
await updateMutation.mutateAsync({ id: role.id, data: updateData });
} else {
const createData: CreateRoleData = {
name: values.name,
description: values.description,
priority: values.priority,
permissionIds: values.permissionIds || [],
};
await createMutation.mutateAsync(createData);
@@ -154,30 +146,6 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={100}
placeholder="0-100 (higher = more important)"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Higher priority roles have precedence (admin=100, user=50, guest=0)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isEditMode && permissions && permissions.length > 0 ? (
<FormField
control={form.control}
+59 -44
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
import { toast } from 'sonner';
@@ -33,6 +33,25 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
const { data: roles, isLoading, isError, error } = useRoles();
const deleteRoleMutation = useDeleteRole();
// Sort roles: system roles (admin=1, user=2, guest=3) pinned at top, then others by name
const sortedRoles = useMemo(() => {
if (!roles) return [];
return [...roles].sort((a, b) => {
const aIsSystem = SYSTEM_ROLE_IDS.has(a.id);
const bIsSystem = SYSTEM_ROLE_IDS.has(b.id);
// System roles come first
if (aIsSystem && !bIsSystem) return -1;
if (!aIsSystem && bIsSystem) return 1;
// Among system roles, sort by ID (admin=1, user=2, guest=3)
if (aIsSystem && bIsSystem) return a.id - b.id;
// Among custom roles, sort by name
return a.name.localeCompare(b.name);
});
}, [roles]);
const handleDeleteClick = (role: Role) => {
setRoleToDelete(role);
setIsDeleteDialogOpen(true);
@@ -84,11 +103,6 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
header: 'Description',
cell: ({ row }) => <div className="text-muted-foreground">{row.getValue('description')}</div>,
},
{
accessorKey: 'priority',
header: 'Priority',
cell: ({ row }) => <div className="text-muted-foreground">{row.getValue('priority')}</div>,
},
{
accessorKey: 'permissions',
header: 'Permissions',
@@ -104,44 +118,45 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
const role = row.original;
const isSystemRole = SYSTEM_ROLE_IDS.has(role.id);
// System roles cannot be modified - show label instead of menu
if (isSystemRole) {
return (
<Badge variant="outline" className="text-muted-foreground">
System Role
</Badge>
);
}
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<RoleGuard permission="roles.update">
<DropdownMenuItem onClick={() => onEdit(role)} disabled={isSystemRole}>
Edit Role
</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.update">
<DropdownMenuItem
onClick={() => onManagePermissions(role)}
disabled={isSystemRole}
>
Manage Permissions
</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.delete">
<DropdownMenuItem
onClick={() => handleDeleteClick(role)}
disabled={deleteRoleMutation.isPending || isSystemRole}
className="text-destructive focus:text-destructive"
>
Delete Role
</DropdownMenuItem>
</RoleGuard>
</DropdownMenuContent>
</DropdownMenu>
{isSystemRole ? <span className="text-xs text-muted-foreground">(System)</span> : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<RoleGuard permission="roles.update">
<DropdownMenuItem onClick={() => onEdit(role)}>Edit Role</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.update">
<DropdownMenuItem onClick={() => onManagePermissions(role)}>
Manage Permissions
</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.delete">
<DropdownMenuItem
onClick={() => handleDeleteClick(role)}
disabled={deleteRoleMutation.isPending}
className="text-destructive focus:text-destructive"
>
Delete Role
</DropdownMenuItem>
</RoleGuard>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
@@ -159,7 +174,7 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
<>
<DataTable
columns={columns}
data={roles || []}
data={sortedRoles}
isLoading={isLoading}
emptyMessage="No roles found"
/>
+60 -8
View File
@@ -16,7 +16,10 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-300 ease-out',
className
)}
{...props}
@@ -34,7 +37,29 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
// Positioning
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
// Size
'w-[calc(100%-2rem)] max-w-lg',
// Glassmorphism effect
'bg-background/80 backdrop-blur-xl',
'border border-border/50',
'shadow-2xl shadow-black/20',
// Ring glow effect
'ring-1 ring-white/10',
// Layout
'grid gap-5 p-6 sm:p-7',
// Shape
'rounded-xl sm:rounded-2xl',
// Animations
'duration-300 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
// Focus
'outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className
)}
{...props}
@@ -44,13 +69,24 @@ const AlertDialogContent = React.forwardRef<
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
<div
className={cn(
'flex flex-col space-y-2',
'text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3',
'pt-2',
className
)}
{...props}
/>
);
@@ -62,7 +98,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
className={cn(
'text-xl font-semibold leading-tight tracking-tight',
'text-foreground',
className
)}
{...props}
/>
));
@@ -74,7 +114,11 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn(
'text-sm leading-relaxed',
'text-muted-foreground',
className
)}
{...props}
/>
));
@@ -84,7 +128,11 @@ const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), 'transition-all duration-200', className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
@@ -94,7 +142,11 @@ const AlertDialogCancel = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
className={cn(
buttonVariants({ variant: 'outline' }),
'transition-all duration-200',
className
)}
{...props}
/>
));
+73 -9
View File
@@ -19,7 +19,10 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-300 ease-out',
className
)}
{...props}
@@ -36,14 +39,47 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border-0 bg-background p-6 shadow-lg duration-200 outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
// Positioning
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
// Size
'w-[calc(100%-2rem)] max-w-lg',
// Glassmorphism effect
'bg-background/80 backdrop-blur-xl',
'border border-border/50',
'shadow-2xl shadow-black/20',
// Ring glow effect
'ring-1 ring-white/10',
// Layout
'grid gap-5 p-6 sm:p-7',
// Shape
'rounded-xl sm:rounded-2xl',
// Animations
'duration-300 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
// Focus
'outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-all hover:opacity-100 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-6 w-6" />
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 sm:right-5 sm:top-5',
'rounded-full p-1.5',
'text-muted-foreground/70 hover:text-foreground',
'bg-transparent hover:bg-muted/80',
'transition-all duration-200 ease-out',
'hover:scale-110 active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none'
)}
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@@ -52,20 +88,40 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
<div
className={cn(
'flex flex-col space-y-2',
'text-center sm:text-left',
'pr-8', // Space for close button
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3',
'pt-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('-mx-4 max-h-[80vh] overflow-y-auto px-4', className)} {...props} />
<div
className={cn(
'-mx-6 sm:-mx-7 px-6 sm:px-7',
'max-h-[60vh] overflow-y-auto',
'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/20',
className
)}
{...props}
/>
);
DialogBody.displayName = 'DialogBody';
@@ -75,7 +131,11 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
className={cn(
'text-xl font-semibold leading-tight tracking-tight',
'text-foreground',
className
)}
{...props}
/>
));
@@ -87,7 +147,11 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn(
'text-sm leading-relaxed',
'text-muted-foreground',
className
)}
{...props}
/>
));
+76 -12
View File
@@ -25,14 +25,18 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'flex cursor-default select-none items-center gap-2',
'rounded-lg px-2.5 py-2 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent data-[state=open]:bg-accent',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
<ChevronRight className="ml-auto opacity-60" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@@ -44,7 +48,28 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-primary/50 bg-popover p-1 text-popover-foreground shadow-lg outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
'z-50 min-w-[8rem] overflow-hidden',
// Glassmorphism
'bg-popover/95 backdrop-blur-xl',
'border border-border/50',
'shadow-xl shadow-black/10',
'ring-1 ring-white/5',
// Shape
'rounded-xl p-1.5',
// Text
'text-popover-foreground',
// Focus
'outline-none',
// Animations
'duration-200 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'origin-[--radix-dropdown-menu-content-transform-origin]',
className
)}
{...props}
@@ -61,7 +86,29 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-primary/50 bg-popover p-1 text-popover-foreground shadow-md outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
'z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden',
'max-h-[var(--radix-dropdown-menu-content-available-height)]',
// Glassmorphism
'bg-popover/95 backdrop-blur-xl',
'border border-border/50',
'shadow-xl shadow-black/10',
'ring-1 ring-white/5',
// Shape
'rounded-xl p-1.5',
// Text
'text-popover-foreground',
// Focus
'outline-none',
// Animations
'duration-200 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'origin-[--radix-dropdown-menu-content-transform-origin]',
className
)}
{...props}
@@ -79,7 +126,12 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex cursor-default select-none items-center gap-2',
'rounded-lg px-2.5 py-2 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
@@ -95,13 +147,17 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center',
'rounded-lg py-2 pl-8 pr-2.5 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -118,12 +174,16 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center',
'rounded-lg py-2 pl-8 pr-2.5 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -141,7 +201,11 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
className={cn(
'px-2.5 py-2 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
@@ -153,7 +217,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
className={cn('-mx-1 my-1.5 h-px bg-border/50', className)}
{...props}
/>
));
@@ -161,7 +225,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
<span className={cn('ml-auto text-xs tracking-widest opacity-50', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
+22 -1
View File
@@ -18,7 +18,28 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'z-[100] w-72 rounded-md border border-primary/50 bg-popover p-4 text-popover-foreground shadow-md outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]',
'z-[100] w-72',
// Glassmorphism
'bg-popover/95 backdrop-blur-xl',
'border border-border/50',
'shadow-xl shadow-black/10',
'ring-1 ring-white/5',
// Shape
'rounded-xl p-4',
// Text
'text-popover-foreground',
// Focus
'outline-none',
// Animations
'duration-200 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'origin-[--radix-popover-content-transform-origin]',
className
)}
{...props}
+73 -14
View File
@@ -19,7 +19,10 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-300 ease-out',
className
)}
{...props}
@@ -29,16 +32,41 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
cn(
'fixed z-50 gap-5',
// Glassmorphism
'bg-background/90 backdrop-blur-xl',
'shadow-2xl shadow-black/20',
// Animation base
'transition-all ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:duration-300 data-[state=open]:duration-300'
),
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
top: cn(
'inset-x-0 top-0 p-6',
'border-b border-border/50',
'rounded-b-2xl',
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top'
),
bottom: cn(
'inset-x-0 bottom-0 p-6',
'border-t border-border/50',
'rounded-t-2xl',
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom'
),
left: cn(
'inset-y-0 left-0 h-full w-[85%] sm:w-3/4 sm:max-w-sm p-6',
'border-r border-border/50',
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left'
),
right: cn(
'inset-y-0 right-0 h-full w-[85%] sm:w-3/4 sm:max-w-sm p-6',
'border-l border-border/50',
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right'
),
},
},
defaultVariants: {
@@ -63,8 +91,19 @@ const SheetContent = React.forwardRef<
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<SheetPrimitive.Close
className={cn(
'absolute right-4 top-4',
'rounded-full p-1.5',
'text-muted-foreground/70 hover:text-foreground',
'bg-transparent hover:bg-muted/80',
'transition-all duration-200 ease-out',
'hover:scale-110 active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none'
)}
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
@@ -73,13 +112,25 @@ const SheetContent = React.forwardRef<
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
<div
className={cn(
'flex flex-col space-y-2',
'text-center sm:text-left',
'pr-8', // Space for close button
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3',
'pt-2',
className
)}
{...props}
/>
);
@@ -91,7 +142,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
className={cn(
'text-xl font-semibold leading-tight tracking-tight',
'text-foreground',
className
)}
{...props}
/>
));
@@ -103,7 +158,11 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn(
'text-sm leading-relaxed',
'text-muted-foreground',
className
)}
{...props}
/>
));
+155 -28
View File
@@ -4,40 +4,167 @@ import { useThemeStore, resolveActualMode } from '@/store/theme';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const TOAST_DURATION = 4000;
const Toaster = ({ ...props }: ToasterProps) => {
const mode = useThemeStore((s) => s.mode);
const actualTheme = resolveActualMode(mode);
return (
<Sonner
theme={actualTheme}
className="toaster group"
icons={{
success: <CircleCheck className="h-4 w-4 text-toast-success-icon" />,
info: <Info className="h-4 w-4 text-toast-info-icon" />,
warning: <TriangleAlert className="h-4 w-4 text-toast-warning-icon" />,
error: <OctagonX className="h-4 w-4 text-toast-error-icon" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
}}
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
success:
'group-[.toaster]:bg-toast-success group-[.toaster]:text-toast-success-foreground group-[.toaster]:border-toast-success-border',
error:
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border',
warning:
'group-[.toaster]:bg-toast-warning group-[.toaster]:text-toast-warning-foreground group-[.toaster]:border-toast-warning-border',
info: 'group-[.toaster]:bg-toast-info group-[.toaster]:text-toast-info-foreground group-[.toaster]:border-toast-info-border',
},
}}
{...props}
/>
<>
<style>{`
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
[data-sonner-toast]::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: currentColor;
opacity: 0.2;
transform-origin: left;
animation: toast-progress ${TOAST_DURATION}ms linear forwards;
}
[data-sonner-toast][data-type="success"]::after {
background: hsl(var(--toast-success-icon));
opacity: 0.6;
}
[data-sonner-toast][data-type="error"]::after {
background: hsl(var(--toast-error-icon));
opacity: 0.6;
}
[data-sonner-toast][data-type="warning"]::after {
background: hsl(var(--toast-warning-icon));
opacity: 0.6;
}
[data-sonner-toast][data-type="info"]::after {
background: hsl(var(--toast-info-icon));
opacity: 0.6;
}
[data-sonner-toast]:hover::after {
animation-play-state: paused;
}
/* Hide progress bar for loading toasts (they don't auto-dismiss) */
[data-sonner-toast][data-type="loading"]::after {
display: none;
}
/* Close button positioning and styling */
[data-sonner-toast] [data-close-button] {
position: absolute !important;
top: 8px !important;
right: 8px !important;
left: auto !important;
bottom: auto !important;
transform: none !important;
background: transparent !important;
border: none !important;
border-radius: 6px !important;
padding: 6px !important;
width: 28px !important;
height: 28px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
color: hsl(var(--muted-foreground));
opacity: 0.6;
transition: opacity 150ms, background-color 150ms, color 150ms;
cursor: pointer;
}
[data-sonner-toast] [data-close-button] svg {
width: 16px !important;
height: 16px !important;
}
[data-sonner-toast] [data-close-button]:hover {
opacity: 1;
background: hsl(var(--muted)) !important;
color: hsl(var(--foreground));
}
[data-sonner-toast] [data-close-button]:focus-visible {
opacity: 1;
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* Type-specific close button colors */
[data-sonner-toast][data-type="success"] [data-close-button] {
color: hsl(var(--toast-success-foreground));
}
[data-sonner-toast][data-type="success"] [data-close-button]:hover {
background: hsl(var(--toast-success-foreground) / 0.15) !important;
color: hsl(var(--toast-success-foreground));
}
[data-sonner-toast][data-type="error"] [data-close-button] {
color: hsl(var(--toast-error-foreground));
}
[data-sonner-toast][data-type="error"] [data-close-button]:hover {
background: hsl(var(--toast-error-foreground) / 0.15) !important;
color: hsl(var(--toast-error-foreground));
}
[data-sonner-toast][data-type="warning"] [data-close-button] {
color: hsl(var(--toast-warning-foreground));
}
[data-sonner-toast][data-type="warning"] [data-close-button]:hover {
background: hsl(var(--toast-warning-foreground) / 0.15) !important;
color: hsl(var(--toast-warning-foreground));
}
[data-sonner-toast][data-type="info"] [data-close-button] {
color: hsl(var(--toast-info-foreground));
}
[data-sonner-toast][data-type="info"] [data-close-button]:hover {
background: hsl(var(--toast-info-foreground) / 0.15) !important;
color: hsl(var(--toast-info-foreground));
}
`}</style>
<Sonner
theme={actualTheme}
className="toaster group"
closeButton
duration={TOAST_DURATION}
icons={{
success: <CircleCheck className="h-4 w-4 text-toast-success-icon" />,
info: <Info className="h-4 w-4 text-toast-info-icon" />,
warning: <TriangleAlert className="h-4 w-4 text-toast-warning-icon" />,
error: <OctagonX className="h-4 w-4 text-toast-error-icon" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
}}
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg group-[.toaster]:overflow-hidden',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
success:
'group-[.toaster]:bg-toast-success group-[.toaster]:text-toast-success-foreground group-[.toaster]:border-toast-success-border',
error:
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border',
warning:
'group-[.toaster]:bg-toast-warning group-[.toaster]:text-toast-warning-foreground group-[.toaster]:border-toast-warning-border',
info: 'group-[.toaster]:bg-toast-info group-[.toaster]:text-toast-info-foreground group-[.toaster]:border-toast-info-border',
},
}}
{...props}
/>
</>
);
};
+158 -2
View File
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useGames, useGame, useRelatedGames } from './useGames';
import { useGames, useGame, useRelatedGames, useRandomGame } from './useGames';
import { mockGame } from '@/test/mocks/handlers';
import { gamesApi } from '@/lib/api';
// Create a wrapper with QueryClient for hooks that use useQuery
function createWrapper() {
@@ -199,4 +200,159 @@ describe('useGames', () => {
expect(result.current.fetchStatus).toBe('idle');
});
});
describe('useRandomGame', () => {
it('should fetch a random game without parameters', async () => {
const { result } = renderHook(() => useRandomGame(), { wrapper });
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeDefined();
expect(result.current.data?.id).toBe(mockGame.id);
});
it('should call gamesApi.getRandom with library parameter', async () => {
const getRandomSpy = vi.spyOn(gamesApi, 'getRandom');
const { result } = renderHook(() => useRandomGame('arcade'), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith('arcade', undefined);
getRandomSpy.mockRestore();
});
it('should call gamesApi.getRandom with platforms parameter', async () => {
const getRandomSpy = vi.spyOn(gamesApi, 'getRandom');
const platforms = ['Flash', 'HTML5'];
const { result } = renderHook(() => useRandomGame(undefined, platforms), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith(undefined, platforms);
getRandomSpy.mockRestore();
});
it('should call gamesApi.getRandom with both library and platforms', async () => {
const getRandomSpy = vi.spyOn(gamesApi, 'getRandom');
const library = 'arcade';
const platforms = ['Flash', 'HTML5'];
const { result } = renderHook(() => useRandomGame(library, platforms), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(getRandomSpy).toHaveBeenCalledWith(library, platforms);
getRandomSpy.mockRestore();
});
it('should preserve platforms array order in API call', async () => {
const getRandomSpy = vi.spyOn(gamesApi, 'getRandom');
const platforms = ['HTML5', 'Flash']; // Intentionally reversed order
const { result } = renderHook(() => useRandomGame(undefined, platforms), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Verify the exact array is passed (order preserved)
expect(getRandomSpy).toHaveBeenCalledWith(undefined, ['HTML5', 'Flash']);
getRandomSpy.mockRestore();
});
it('should use correct queryKey with library and platforms', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
},
});
const customWrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const library = 'theatre';
const platforms = ['Flash', 'HTML5'];
const { result } = renderHook(() => useRandomGame(library, platforms), {
wrapper: customWrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Check that the query is cached with the expected key
const cachedData = queryClient.getQueryData(['games', 'random', library, platforms]);
expect(cachedData).toBeDefined();
// Verify different parameters create different cache entries
const otherCachedData = queryClient.getQueryData(['games', 'random', 'arcade', platforms]);
expect(otherCachedData).toBeUndefined();
});
it('should create separate cache entries for different platforms arrays', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
},
});
const customWrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const platforms1 = ['Flash'];
const platforms2 = ['Flash', 'HTML5'];
// First query with single platform
const { result: result1 } = renderHook(() => useRandomGame(undefined, platforms1), {
wrapper: customWrapper,
});
await waitFor(() => {
expect(result1.current.isSuccess).toBe(true);
});
// Second query with multiple platforms
const { result: result2 } = renderHook(() => useRandomGame(undefined, platforms2), {
wrapper: customWrapper,
});
await waitFor(() => {
expect(result2.current.isSuccess).toBe(true);
});
// Both should be cached separately
const cached1 = queryClient.getQueryData(['games', 'random', undefined, platforms1]);
const cached2 = queryClient.getQueryData(['games', 'random', undefined, platforms2]);
expect(cached1).toBeDefined();
expect(cached2).toBeDefined();
});
});
});
+3 -3
View File
@@ -29,10 +29,10 @@ export function useRelatedGames(id: string, limit = 10) {
});
}
export function useRandomGame(library?: string) {
export function useRandomGame(library?: string, platforms?: string[]) {
return useQuery({
queryKey: ['games', 'random', library],
queryFn: () => gamesApi.getRandom(library),
queryKey: ['games', 'random', library, platforms],
queryFn: () => gamesApi.getRandom(library, platforms),
});
}
+5 -2
View File
@@ -33,9 +33,12 @@ export const gamesApi = {
return data;
},
getRandom: async (library?: string): Promise<Game> => {
getRandom: async (library?: string, platforms?: string[]): Promise<Game> => {
const { data } = await apiClient.get<Game>('/games/random', {
params: { library },
params: {
library,
platforms: platforms?.join(','),
},
});
return data;
},
+152
View File
@@ -0,0 +1,152 @@
import type { BreadcrumbContext } from '@/components/common/Breadcrumbs';
/**
* Icon configuration - either an image path or a Lucide icon name
*/
export interface SectionIcon {
type: 'image' | 'lucide';
value: string; // Image path or Lucide icon name
}
/**
* Section configuration for game routes
*/
export interface SectionConfig {
/** URL prefix for game routes (e.g., '/flash') */
prefix: string;
/** Sidebar item path (e.g., '/flash') */
sidebarPath: string;
/** Label for breadcrumbs */
breadcrumbLabel: string;
/** Href for breadcrumb link back to section */
breadcrumbHref: string;
/** Icon for the section */
icon: SectionIcon;
}
/**
* Section configurations keyed by section identifier
*/
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
flash: {
prefix: '/flash',
sidebarPath: '/flash',
breadcrumbLabel: 'Flash Games',
breadcrumbHref: '/flash',
icon: { type: 'image', value: '/images/Flash.png' },
},
html5: {
prefix: '/html5',
sidebarPath: '/html5',
breadcrumbLabel: 'HTML5 Games',
breadcrumbHref: '/html5',
icon: { type: 'image', value: '/images/HTML5.png' },
},
animations: {
prefix: '/animations',
sidebarPath: '/animations',
breadcrumbLabel: 'Animations',
breadcrumbHref: '/animations',
icon: { type: 'lucide', value: 'Film' },
},
browse: {
prefix: '/browse',
sidebarPath: '/browse',
breadcrumbLabel: 'Browse',
breadcrumbHref: '/browse',
icon: { type: 'lucide', value: 'Gamepad2' },
},
};
/**
* Map of sidebar paths to URL prefixes for active state detection.
* Used by SidebarItem ONLY when no breadcrumb context is available
* (e.g., direct URL access without navigation state).
* When breadcrumb context exists, it takes priority over prefix matching.
*/
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
'/flash': ['/flash/'],
'/html5': ['/html5/'],
'/animations': ['/animations/'],
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
'/playlists': ['/playlists/'],
'/flashpoint-playlists': ['/flashpoint-playlists/'],
'/favorites': ['/favorites/'],
};
/**
* Get section configuration from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @returns Section config if pathname matches a section route, null otherwise
*/
export function getSectionFromPath(pathname: string): SectionConfig | null {
for (const config of Object.values(SECTION_CONFIGS)) {
if (pathname.startsWith(`${config.prefix}/`)) {
return config;
}
}
return null;
}
/**
* Get section key from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @returns Section key ('flash', 'html5', etc.) or null
*/
export function getSectionKeyFromPath(pathname: string): string | null {
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
if (pathname.startsWith(`${config.prefix}/`)) {
return key;
}
}
return null;
}
/**
* Build a section-based game URL
* @param gameId - Game UUID
* @param sectionKey - Section key ('flash', 'html5', 'animations', 'browse') or null
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
* @returns URL string (e.g., '/flash/abc-123' or '/flash/abc-123/play')
*/
export function buildSectionGameUrl(
gameId: string,
sectionKey: string | null | undefined,
isPlay: boolean = false
): string {
const suffix = isPlay ? '/play' : '';
const config = sectionKey ? SECTION_CONFIGS[sectionKey] : null;
if (config) {
return `${config.prefix}/${gameId}${suffix}`;
}
// Fallback to legacy route
return `/games/${gameId}${suffix}`;
}
/**
* Derive breadcrumb context from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
*/
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {
const section = getSectionFromPath(pathname);
if (section) {
return {
label: section.breadcrumbLabel,
href: section.breadcrumbHref,
icon: section.icon,
};
}
return null;
}
/**
* Default breadcrumb context when no section can be determined
*/
export const DEFAULT_BREADCRUMB_CONTEXT: BreadcrumbContext = {
label: 'Browse',
href: '/browse',
icon: SECTION_CONFIGS.browse.icon,
};
+5 -4
View File
@@ -84,6 +84,11 @@ export const handlers = [
});
}),
// Static routes MUST come before parameterized routes
http.get(`/api/games/random`, () => {
return HttpResponse.json(mockGame);
}),
http.get(`/api/games/:id`, ({ params }) => {
return HttpResponse.json({ ...mockGame, id: params.id });
}),
@@ -92,10 +97,6 @@ export const handlers = [
return HttpResponse.json([mockGame]);
}),
http.get(`/api/games/random`, () => {
return HttpResponse.json(mockGame);
}),
http.get(`/api/games/:id/launch`, ({ params }) => {
return HttpResponse.json({
gameId: params.id,
-3
View File
@@ -59,7 +59,6 @@ export interface Role {
id: number;
name: string;
description: string;
priority: number;
createdAt: string;
updatedAt: string;
permissions: Permission[];
@@ -68,14 +67,12 @@ export interface Role {
export interface CreateRoleData {
name: string;
description?: string;
priority?: number;
permissionIds?: number[];
}
export interface UpdateRoleData {
name?: string;
description?: string;
priority?: number;
}
export interface CreateUserData {
+1
View File
@@ -6,6 +6,7 @@ export function AnimationsView() {
title="Browse Animations"
library="theatre"
breadcrumbContext={{ label: 'Animations', href: '/animations' }}
sectionKey="animations"
/>
);
}
+1
View File
@@ -6,6 +6,7 @@ export function BrowseView() {
title="Browse Games"
library="arcade"
breadcrumbContext={{ label: 'Browse', href: '/browse' }}
sectionKey="browse"
/>
);
}
+10 -2
View File
@@ -129,14 +129,22 @@ export function FavoritesView() {
games={games}
favoriteGameIds={favoriteGameIds}
isFavoritePage={true}
breadcrumbContext={{ label: 'Favorites', href: '/favorites' }}
breadcrumbContext={{
label: 'Favorites',
href: '/favorites',
icon: { type: 'lucide', value: 'Heart' },
}}
/>
) : (
<GameList
games={games}
favoriteGameIds={favoriteGameIds}
isFavoritePage={true}
breadcrumbContext={{ label: 'Favorites', href: '/favorites' }}
breadcrumbContext={{
label: 'Favorites',
href: '/favorites',
icon: { type: 'lucide', value: 'Heart' },
}}
/>
)}
</>
+2 -1
View File
@@ -6,7 +6,8 @@ export function FlashGamesView() {
title="Flash Games"
library="arcade"
platform="Flash"
breadcrumbContext={{ label: 'Flash Games', href: '/flash-games' }}
breadcrumbContext={{ label: 'Flash Games', href: '/flash' }}
sectionKey="flash"
/>
);
}
+69 -30
View File
@@ -12,12 +12,16 @@ import { Badge } from '@/components/ui/badge';
import { GameInfoGrid } from '@/components/game/GameInfoGrid';
import { useDateTimeFormat } from '@/hooks/useDateTimeFormat';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { Breadcrumbs } from '@/components/common/Breadcrumbs';
import { Breadcrumbs, BreadcrumbContext, BreadcrumbItem } from '@/components/common/Breadcrumbs';
import { buildSharedGameUrl } from '@/hooks/useSharedPlaylistAccess';
import { BreadcrumbContext } from '@/components/common/Breadcrumbs';
import { getGameLogoUrl, getGameScreenshotUrl } from '@/utils/gameUtils';
import { logger } from '@/lib/logger';
import { formatDuration } from '@/lib/cron-utils';
import {
getBreadcrumbContextFromPath,
getSectionFromPath,
DEFAULT_BREADCRUMB_CONTEXT,
} from '@/lib/sectionRoutes';
export function GameDetailView() {
const { id } = useParams<{ id: string }>();
@@ -28,7 +32,13 @@ export function GameDetailView() {
const { isAuthenticated } = useAuthStore();
const { generateToken, hasValidToken, isGenerating } = useSharedAccessToken();
const breadcrumbContext = location.state?.breadcrumbContext as BreadcrumbContext | undefined;
// Derive breadcrumb context: URL path takes precedence, then state, then default
const urlBreadcrumbContext = getBreadcrumbContextFromPath(location.pathname);
const stateBreadcrumbContext = location.state?.breadcrumbContext as BreadcrumbContext | undefined;
const breadcrumbContext = urlBreadcrumbContext ?? stateBreadcrumbContext ?? DEFAULT_BREADCRUMB_CONTEXT;
// Get current section for building play URLs
const currentSection = getSectionFromPath(location.pathname);
useEffect(() => {
if (shareToken && !isAuthenticated && !hasValidToken) {
@@ -119,12 +129,21 @@ export function GameDetailView() {
!isRefetchingAfterDownload.current
) {
setShouldAutoPlay(false);
navigate(buildSharedGameUrl(`/games/${game.id}/play`, shareToken), {
// Build play URL: use section-based route if on section page, otherwise shared/legacy
const playUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: currentSection
? `${currentSection.prefix}/${game.id}/play`
: `/games/${game.id}/play`;
const detailUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: location.pathname;
navigate(playUrl, {
state: {
playerBreadcrumbContext: {
breadcrumbContext,
gameTitle: game.title,
gameDetailHref: buildSharedGameUrl(`/games/${game.id}`, shareToken),
gameDetailHref: detailUrl,
shareToken: shareToken ?? null,
sharedPlaylistTitle: sharedPlaylist?.title ?? null,
sharedPlaylistHref: shareToken ? `/playlists/shared/${shareToken}` : null,
@@ -140,6 +159,8 @@ export function GameDetailView() {
shareToken,
breadcrumbContext,
sharedPlaylist,
currentSection,
location.pathname,
]);
const handleStartDownload = async () => {
@@ -182,32 +203,42 @@ export function GameDetailView() {
// presentOnDisk: null = no data needed, 0 = needs download, 1 = downloaded
const needsDataDownload = game.presentOnDisk === 0;
const breadcrumbItems =
shareToken && sharedPlaylist
? [
{ label: sharedPlaylist.title, href: `/playlists/shared/${shareToken}` },
{ label: game.title, active: true },
]
: shareToken
? [{ label: game.title, active: true }]
: breadcrumbContext
? [
{ label: breadcrumbContext.label, href: breadcrumbContext.href },
{ label: game.title, active: true },
]
: [
{ label: 'Browse', href: '/browse' },
{ label: game.title, active: true },
];
// Build breadcrumb items, handling optional parent context
const buildBreadcrumbItems = () => {
if (shareToken && sharedPlaylist) {
return [
{ label: sharedPlaylist.title, href: `/playlists/shared/${shareToken}` },
{ label: game.title, active: true },
];
}
if (shareToken) {
return [{ label: game.title, active: true }];
}
// Build items from context, including parent if present
const items: BreadcrumbItem[] = [];
if (breadcrumbContext.parent) {
items.push({
label: breadcrumbContext.parent.label,
href: breadcrumbContext.parent.href,
icon: breadcrumbContext.parent.icon,
});
}
items.push({
label: breadcrumbContext.label,
href: breadcrumbContext.href,
icon: breadcrumbContext.icon,
});
items.push({ label: game.title, active: true });
return items;
};
const breadcrumbItems = buildBreadcrumbItems();
return (
<ErrorBoundary>
<div className="max-w-6xl mx-auto space-y-6">
<Breadcrumbs
items={breadcrumbItems}
homeLabel={shareToken ? 'Shared' : 'Home'}
homeHref={shareToken ? '#' : '/'}
/>
<div className="max-w-7xl mx-auto space-y-6">
<Breadcrumbs items={breadcrumbItems} />
<div className="bg-card rounded-lg p-6 space-y-6 border border-border shadow-md">
<div className="flex items-start justify-between gap-4">
@@ -288,12 +319,20 @@ export function GameDetailView() {
</button>
) : (
<Link
to={buildSharedGameUrl(`/games/${game.id}/play`, shareToken)}
to={
shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: currentSection
? `${currentSection.prefix}/${game.id}/play`
: `/games/${game.id}/play`
}
state={{
playerBreadcrumbContext: {
breadcrumbContext,
gameTitle: game.title,
gameDetailHref: buildSharedGameUrl(`/games/${game.id}`, shareToken),
gameDetailHref: shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: location.pathname,
shareToken: shareToken ?? null,
sharedPlaylistTitle: sharedPlaylist?.title ?? null,
sharedPlaylistHref: shareToken ? `/playlists/shared/${shareToken}` : null,
+42 -22
View File
@@ -1,5 +1,5 @@
import { useParams, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { AlertCircle, Loader2, Download } from 'lucide-react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import {
Breadcrumbs,
@@ -14,6 +14,11 @@ import { usePlaySession } from '@/hooks/usePlayTracking';
import { GameInfoGrid } from '@/components/game/GameInfoGrid';
import { getGameLogoUrl } from '@/utils/gameUtils';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import {
getBreadcrumbContextFromPath,
getSectionFromPath,
DEFAULT_BREADCRUMB_CONTEXT,
} from '@/lib/sectionRoutes';
export function GamePlayerView() {
const { id } = useParams<{ id: string }>();
@@ -67,6 +72,10 @@ export function GamePlayerView() {
]
);
// Derive breadcrumb context from URL when no state is available
const urlBreadcrumbContext = getBreadcrumbContextFromPath(location.pathname);
const currentSection = getSectionFromPath(location.pathname);
const breadcrumbItems = useMemo((): BreadcrumbItem[] => {
const items: BreadcrumbItem[] = [];
@@ -76,16 +85,40 @@ export function GamePlayerView() {
href: playerContext.sharedPlaylistHref ?? '#',
});
} else if (playerContext?.breadcrumbContext) {
// Add parent context if present (e.g., Flashpoint Playlists > Playlist Name)
if (playerContext.breadcrumbContext.parent) {
items.push({
label: playerContext.breadcrumbContext.parent.label,
href: playerContext.breadcrumbContext.parent.href,
icon: playerContext.breadcrumbContext.parent.icon,
});
}
items.push({
label: playerContext.breadcrumbContext.label,
href: playerContext.breadcrumbContext.href,
icon: playerContext.breadcrumbContext.icon,
});
} else if (urlBreadcrumbContext) {
// Derive from URL when no state (direct URL access)
items.push({
label: urlBreadcrumbContext.label,
href: urlBreadcrumbContext.href,
icon: urlBreadcrumbContext.icon,
});
} else {
items.push({ label: 'Browse', href: '/browse' });
items.push({
label: DEFAULT_BREADCRUMB_CONTEXT.label,
href: DEFAULT_BREADCRUMB_CONTEXT.href,
icon: DEFAULT_BREADCRUMB_CONTEXT.icon,
});
}
// Build game detail URL: use section route if available, otherwise state/legacy
const gameDetailHref =
playerContext?.gameDetailHref ?? buildSharedGameUrl(`/games/${id}`, shareToken);
playerContext?.gameDetailHref ??
(currentSection
? `${currentSection.prefix}/${id}`
: buildSharedGameUrl(`/games/${id}`, shareToken));
items.push({
label: playerContext?.gameTitle ?? game?.title ?? 'Game',
href: gameDetailHref,
@@ -94,7 +127,7 @@ export function GamePlayerView() {
items.push({ label: 'Play', active: true });
return items;
}, [id, game?.title, shareToken, playerContext]);
}, [id, game?.title, shareToken, playerContext, urlBreadcrumbContext, currentSection]);
if (gameLoading || launchLoading) {
return (
@@ -131,14 +164,8 @@ export function GamePlayerView() {
// This prevents the game from unmounting/remounting when toggling fullscreen
return (
<ErrorBoundary>
<div className={isFullscreen ? 'fixed inset-0 z-50 bg-black' : 'max-w-6xl mx-auto space-y-6'}>
{!isFullscreen ? (
<Breadcrumbs
items={breadcrumbItems}
homeLabel={shareToken ? 'Shared' : 'Home'}
homeHref={shareToken ? '#' : '/'}
/>
) : null}
<div className={isFullscreen ? 'fixed inset-0 z-50 bg-black' : 'max-w-7xl mx-auto space-y-6'}>
{!isFullscreen ? <Breadcrumbs items={breadcrumbItems} /> : null}
<div
className={
@@ -183,16 +210,9 @@ export function GamePlayerView() {
{/* Game Player Container - ALWAYS VISIBLE, persists in both modes */}
<div className={isFullscreen ? 'w-full h-screen' : 'aspect-video bg-black'}>
{launchData?.downloading ? (
<div className="flex items-center justify-center h-full bg-black">
<div className="text-center max-w-sm">
<Download size={48} className="text-blue-500 mx-auto mb-4 animate-bounce" />
<h3 className="text-xl font-bold mb-2 text-white">Downloading Game Data...</h3>
<p className="text-muted-foreground text-sm mb-4">
The game files are being downloaded. This page will update automatically when
ready.
</p>
<Loader2 size={20} className="text-blue-400 mx-auto animate-spin" />
</div>
<div className="flex items-center justify-center h-full bg-black gap-3">
<Loader2 size={24} className="text-primary animate-spin" />
<span className="text-white text-lg">Downloading...</span>
</div>
) : (
<GamePlayer
+2 -1
View File
@@ -6,7 +6,8 @@ export function HTML5GamesView() {
title="HTML5 Games"
library="arcade"
platform="HTML5"
breadcrumbContext={{ label: 'HTML5 Games', href: '/html5-games' }}
breadcrumbContext={{ label: 'HTML5 Games', href: '/html5' }}
sectionKey="html5"
/>
);
}
+21 -9
View File
@@ -2,10 +2,10 @@ import { useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { usePlaylist } from '@/hooks/usePlaylists';
import { useFavoriteGameIds } from '@/hooks/useFavorites';
import { ArrowLeft } from 'lucide-react';
import { GameGrid } from '@/components/library/GameGrid';
import { Badge } from '@/components/ui/badge';
import { useAuthStore } from '@/store/auth';
import { Breadcrumbs } from '@/components/common/Breadcrumbs';
export function PlaylistDetailView() {
const { id } = useParams<{ id: string }>();
@@ -40,13 +40,17 @@ export function PlaylistDetailView() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<Link
to="/flashpoint-playlists"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={20} />
Back to playlists
</Link>
<Breadcrumbs
items={[
{
label: 'Flashpoint Playlists',
href: '/flashpoint-playlists',
icon: { type: 'lucide', value: 'List' },
},
{ label: playlist.title, active: true },
]}
fallbackHref="/flashpoint-playlists"
/>
<div>
<div className="flex items-center gap-3 mb-2">
@@ -69,7 +73,15 @@ export function PlaylistDetailView() {
<GameGrid
games={playlist.games}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={{ label: playlist.title, href: `/flashpoint-playlists/${id ?? ''}` }}
breadcrumbContext={{
label: playlist.title,
href: `/flashpoint-playlists/${id ?? ''}`,
parent: {
label: 'Flashpoint Playlists',
href: '/flashpoint-playlists',
icon: { type: 'lucide', value: 'List' },
},
}}
/>
</>
) : (
+1 -5
View File
@@ -110,11 +110,7 @@ export function SharedPlaylistView() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<Breadcrumbs
items={[{ label: playlist.title, active: true }]}
homeLabel="Shared"
homeHref="#"
/>
<Breadcrumbs items={[{ label: playlist.title, active: true }]} />
<Alert>
<Info className="h-4 w-4" />
+31 -10
View File
@@ -6,7 +6,8 @@ import {
useDeleteUserPlaylist,
} from '@/hooks/useUserPlaylists';
import { useFavoriteGameIds } from '@/hooks/useFavorites';
import { ArrowLeft, Edit, Trash2, MoreVertical, Share2 } from 'lucide-react';
import { Edit, Trash2, MoreVertical, Share2 } from 'lucide-react';
import { Breadcrumbs } from '@/components/common/Breadcrumbs';
import { GameGrid } from '@/components/library/GameGrid';
import { GameList } from '@/components/library/GameList';
import { Button } from '@/components/ui/button';
@@ -101,13 +102,17 @@ export function UserPlaylistDetailView() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<Link
to="/playlists"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={20} />
Back to playlists
</Link>
<Breadcrumbs
items={[
{
label: 'My Playlists',
href: '/playlists',
icon: { type: 'lucide', value: 'ListVideo' },
},
{ label: playlist.title, active: true },
]}
fallbackHref="/playlists"
/>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex gap-4">
@@ -181,13 +186,29 @@ export function UserPlaylistDetailView() {
<GameGrid
games={playlistGames}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={{ label: playlist.title, href: `/playlists/${playlist.id}` }}
breadcrumbContext={{
label: playlist.title,
href: `/playlists/${playlist.id}`,
parent: {
label: 'My Playlists',
href: '/playlists',
icon: { type: 'lucide', value: 'ListVideo' },
},
}}
/>
) : (
<GameList
games={playlistGames}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={{ label: playlist.title, href: `/playlists/${playlist.id}` }}
breadcrumbContext={{
label: playlist.title,
href: `/playlists/${playlist.id}`,
parent: {
label: 'My Playlists',
href: '/playlists',
icon: { type: 'lucide', value: 'ListVideo' },
},
}}
/>
)}
</>
+4 -1
View File
@@ -1,3 +1,6 @@
import tailwindcssAnimate from 'tailwindcss-animate';
import typography from '@tailwindcss/typography';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
@@ -187,5 +190,5 @@ export default {
},
},
},
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
plugins: [tailwindcssAnimate, typography],
};