Merge pull request #7 from darkraise/feature/ui-enhancement
Feature/UI enhancement
This commit is contained in:
@@ -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,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,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,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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
priority: number;
|
||||
permissions: Permission[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -132,7 +132,6 @@ export interface RoleRow {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
+31
-13
@@ -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
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ export function AnimationsView() {
|
||||
title="Browse Animations"
|
||||
library="theatre"
|
||||
breadcrumbContext={{ label: 'Animations', href: '/animations' }}
|
||||
sectionKey="animations"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export function BrowseView() {
|
||||
title="Browse Games"
|
||||
library="arcade"
|
||||
breadcrumbContext={{ label: 'Browse', href: '/browse' }}
|
||||
sectionKey="browse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user