feat: enhance UI with glassmorphism and improve navigation context

- Add glassmorphism effect to dialog, sheet, popover, dropdown, and
  alert-dialog components with backdrop blur and subtle borders
- Improve sidebar highlighting by prioritizing breadcrumb context over
  URL prefix matching for accurate section detection
- Add parent breadcrumb support for nested navigation (e.g., Flashpoint
  Playlists > Playlist Name > Game)
- Replace "Back to" links with consistent Breadcrumbs component in
  playlist detail views
- Add platform filter to random game endpoint (limit Lucky button to
  Flash/HTML5)
- Remove unused role priority field from schema, types, and UI
- Sort roles table with system roles pinned at top, show "System Role"
  badge instead of disabled actions
This commit is contained in:
Darkraise
2026-02-13 01:19:11 +07:00
parent d2931f3942
commit 9c472392fe
28 changed files with 549 additions and 217 deletions
@@ -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
+13 -1
View File
@@ -131,8 +131,20 @@ 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' && p.trim());
}
}
const game = await gameService.getRandomGame(library, platforms);
if (!game) {
throw new AppError(404, 'No games found');
+1 -4
View File
@@ -13,14 +13,12 @@ const roleService = new RoleService();
const createRoleSchema = z.object({
name: z.string().min(3).max(50),
description: z.string().optional(),
priority: z.number().int().default(0),
permissionIds: z.array(z.number().int().positive()).optional(),
});
const updateRoleSchema = z.object({
name: z.string().min(3).max(50).optional(),
description: z.string().optional(),
priority: z.number().int().optional(),
});
const updatePermissionsSchema = z.object({
@@ -87,7 +85,6 @@ router.post(
const role = await roleService.createRole(
data.name,
data.description,
data.priority,
data.permissionIds || []
);
@@ -108,7 +105,7 @@ router.patch(
const data = updateRoleSchema.parse(req.body);
const role = await roleService.updateRole(id, data.name, data.description, data.priority);
const role = await roleService.updateRole(id, data.name, data.description);
res.json(role);
})
+1 -2
View File
@@ -46,10 +46,9 @@ export class AuthService {
email: string;
password_hash: string;
role_name: string;
priority: number;
}>(
`SELECT u.id, u.username, u.email, u.password_hash,
r.name as role_name, r.priority
r.name as role_name
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.username = ? AND u.is_active = 1`,
+7 -1
View File
@@ -612,7 +612,7 @@ export class GameService {
}
}
async getRandomGame(library?: string): Promise<Game | null> {
async getRandomGame(library?: string, platforms?: string[]): Promise<Game | null> {
try {
let sql = `
SELECT
@@ -631,6 +631,12 @@ export class GameService {
params.push(library);
}
if (platforms && platforms.length > 0) {
const placeholders = platforms.map(() => '?').join(', ');
sql += ` AND g.platformName IN (${placeholders})`;
params.push(...platforms);
}
sql += ` ORDER BY RANDOM() LIMIT 1`;
const game = DatabaseService.get(sql, params) as Game | null;
+6 -21
View File
@@ -16,13 +16,12 @@ export class RoleService {
id: number;
name: string;
description: string | null;
priority: number;
createdAt: string;
updatedAt: string;
}>(
`SELECT id, name, description, priority, created_at as createdAt, updated_at as updatedAt
`SELECT id, name, description, created_at as createdAt, updated_at as updatedAt
FROM roles
ORDER BY priority DESC, name ASC`,
ORDER BY name ASC`,
[]
);
@@ -61,7 +60,6 @@ export class RoleService {
id: role.id,
name: role.name,
description: role.description ?? undefined,
priority: role.priority,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
permissions: permissionsByRole.get(role.id) || [],
@@ -73,11 +71,10 @@ export class RoleService {
id: number;
name: string;
description: string | null;
priority: number;
createdAt: string;
updatedAt: string;
}>(
`SELECT id, name, description, priority, created_at as createdAt, updated_at as updatedAt
`SELECT id, name, description, created_at as createdAt, updated_at as updatedAt
FROM roles WHERE id = ?`,
[id]
);
@@ -88,7 +85,6 @@ export class RoleService {
id: role.id,
name: role.name,
description: role.description ?? undefined,
priority: role.priority,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
permissions: this.getRolePermissions(id),
@@ -140,7 +136,6 @@ export class RoleService {
async createRole(
name: string,
description?: string,
priority: number = 0,
permissionIds: number[] = []
): Promise<Role> {
// Wrap in transaction to prevent TOCTOU race on name uniqueness
@@ -152,8 +147,8 @@ export class RoleService {
}
const result = db
.prepare('INSERT INTO roles (name, description, priority) VALUES (?, ?, ?)')
.run(name, description, priority);
.prepare('INSERT INTO roles (name, description) VALUES (?, ?)')
.run(name, description);
return result.lastInsertRowid as number;
})();
@@ -166,12 +161,7 @@ export class RoleService {
return (await this.getRoleById(roleId))!;
}
async updateRole(
id: number,
name?: string,
description?: string,
priority?: number
): Promise<Role> {
async updateRole(id: number, name?: string, description?: string): Promise<Role> {
const role = await this.getRoleById(id);
if (!role) {
throw new AppError(404, 'Role not found');
@@ -195,11 +185,6 @@ export class RoleService {
params.push(description);
}
if (priority !== undefined) {
updates.push('priority = ?');
params.push(priority);
}
if (updates.length === 0) {
return role;
}
+2 -2
View File
@@ -74,8 +74,8 @@ export function createTestRole(
permissions: string[] = []
) {
const result = db
.prepare(`INSERT INTO roles (name, description, priority) VALUES (?, ?, ?)`)
.run(name, `Test role: ${name}`, 50);
.prepare(`INSERT INTO roles (name, description) VALUES (?, ?)`)
.run(name, `Test role: ${name}`);
const roleId = result.lastInsertRowid as number;
-1
View File
@@ -81,7 +81,6 @@ export interface Role {
id: number;
name: string;
description?: string;
priority: number;
permissions: Permission[];
createdAt: string;
updatedAt: string;
-1
View File
@@ -132,7 +132,6 @@ export interface RoleRow {
id: number;
name: string;
description: string | null;
priority: number;
created_at: string;
updated_at: string;
}
@@ -1,5 +1,5 @@
import { Link, useNavigate } from 'react-router-dom';
import { ChevronRight, ArrowLeft, Film, Gamepad2 } 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';
@@ -7,6 +7,8 @@ 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 {
@@ -54,6 +56,12 @@ function BreadcrumbIcon({ icon }: { icon: SectionIcon }) {
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;
}
+2 -1
View File
@@ -22,7 +22,8 @@ export function AppShell({ children }: AppShellProps) {
const handleFeelingLucky = async () => {
setIsNavigating(true);
try {
const game = await gamesApi.getRandom();
// Only show Flash and HTML5 games (web-playable platforms)
const game = await gamesApi.getRandom(undefined, ['Flash', 'HTML5']);
// Navigate to browse section with home as the originating context
navigate(`/browse/${game.id}`, {
state: { breadcrumbContext: { label: 'Home', href: '/' } },
+24 -12
View File
@@ -37,22 +37,34 @@ export function SidebarItem({
return true;
}
// Check if current path is a section-based game route that maps to this sidebar item
const prefixes = SIDEBAR_PREFIX_MAP[itemPath];
if (prefixes?.some((prefix) => location.pathname.startsWith(prefix))) {
return true;
}
// Check breadcrumb context from location state (fallback for non-section routes)
// 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 };
playerBreadcrumbContext?: { breadcrumbContext?: { href?: string } };
breadcrumbContext?: { href?: string; parent?: { href?: string } };
playerBreadcrumbContext?: { breadcrumbContext?: { href?: string; parent?: { href?: string } } };
} | null;
const breadcrumbHref =
state?.breadcrumbContext?.href ?? state?.playerBreadcrumbContext?.breadcrumbContext?.href;
const breadcrumbContext =
state?.breadcrumbContext ?? state?.playerBreadcrumbContext?.breadcrumbContext;
if (breadcrumbHref && breadcrumbHref === itemPath) {
// 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;
}
@@ -34,10 +34,6 @@ const roleSchema = z.object({
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name must be at most 50 characters'),
description: z.string().optional(),
priority: z
.number()
.min(0, 'Priority must be at least 0')
.max(100, 'Priority must be at most 100'),
permissionIds: z.array(z.number()).optional(),
});
@@ -61,7 +57,6 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
defaultValues: {
name: role?.name ?? '',
description: role?.description ?? '',
priority: role?.priority ?? 0,
permissionIds: role?.permissions?.map((p) => p.id) ?? [],
},
});
@@ -71,7 +66,6 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
form.reset({
name: role.name,
description: role.description,
priority: role.priority,
permissionIds: role.permissions?.map((p) => p.id) || [],
});
}
@@ -83,14 +77,12 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
const updateData: UpdateRoleData = {
name: values.name,
description: values.description,
priority: values.priority,
};
await updateMutation.mutateAsync({ id: role.id, data: updateData });
} else {
const createData: CreateRoleData = {
name: values.name,
description: values.description,
priority: values.priority,
permissionIds: values.permissionIds || [],
};
await createMutation.mutateAsync(createData);
@@ -154,30 +146,6 @@ export function RoleForm({ role, onClose, onSuccess }: RoleFormProps) {
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={100}
placeholder="0-100 (higher = more important)"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Higher priority roles have precedence (admin=100, user=50, guest=0)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isEditMode && permissions && permissions.length > 0 ? (
<FormField
control={form.control}
+59 -44
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
import { toast } from 'sonner';
@@ -33,6 +33,25 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
const { data: roles, isLoading, isError, error } = useRoles();
const deleteRoleMutation = useDeleteRole();
// Sort roles: system roles (admin=1, user=2, guest=3) pinned at top, then others by name
const sortedRoles = useMemo(() => {
if (!roles) return [];
return [...roles].sort((a, b) => {
const aIsSystem = SYSTEM_ROLE_IDS.has(a.id);
const bIsSystem = SYSTEM_ROLE_IDS.has(b.id);
// System roles come first
if (aIsSystem && !bIsSystem) return -1;
if (!aIsSystem && bIsSystem) return 1;
// Among system roles, sort by ID (admin=1, user=2, guest=3)
if (aIsSystem && bIsSystem) return a.id - b.id;
// Among custom roles, sort by name
return a.name.localeCompare(b.name);
});
}, [roles]);
const handleDeleteClick = (role: Role) => {
setRoleToDelete(role);
setIsDeleteDialogOpen(true);
@@ -84,11 +103,6 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
header: 'Description',
cell: ({ row }) => <div className="text-muted-foreground">{row.getValue('description')}</div>,
},
{
accessorKey: 'priority',
header: 'Priority',
cell: ({ row }) => <div className="text-muted-foreground">{row.getValue('priority')}</div>,
},
{
accessorKey: 'permissions',
header: 'Permissions',
@@ -104,44 +118,45 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
const role = row.original;
const isSystemRole = SYSTEM_ROLE_IDS.has(role.id);
// System roles cannot be modified - show label instead of menu
if (isSystemRole) {
return (
<Badge variant="outline" className="text-muted-foreground">
System Role
</Badge>
);
}
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<RoleGuard permission="roles.update">
<DropdownMenuItem onClick={() => onEdit(role)} disabled={isSystemRole}>
Edit Role
</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.update">
<DropdownMenuItem
onClick={() => onManagePermissions(role)}
disabled={isSystemRole}
>
Manage Permissions
</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.delete">
<DropdownMenuItem
onClick={() => handleDeleteClick(role)}
disabled={deleteRoleMutation.isPending || isSystemRole}
className="text-destructive focus:text-destructive"
>
Delete Role
</DropdownMenuItem>
</RoleGuard>
</DropdownMenuContent>
</DropdownMenu>
{isSystemRole ? <span className="text-xs text-muted-foreground">(System)</span> : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<RoleGuard permission="roles.update">
<DropdownMenuItem onClick={() => onEdit(role)}>Edit Role</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.update">
<DropdownMenuItem onClick={() => onManagePermissions(role)}>
Manage Permissions
</DropdownMenuItem>
</RoleGuard>
<RoleGuard permission="roles.delete">
<DropdownMenuItem
onClick={() => handleDeleteClick(role)}
disabled={deleteRoleMutation.isPending}
className="text-destructive focus:text-destructive"
>
Delete Role
</DropdownMenuItem>
</RoleGuard>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
@@ -159,7 +174,7 @@ export function RoleTable({ onEdit, onManagePermissions }: RoleTableProps) {
<>
<DataTable
columns={columns}
data={roles || []}
data={sortedRoles}
isLoading={isLoading}
emptyMessage="No roles found"
/>
+60 -8
View File
@@ -16,7 +16,10 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-300 ease-out',
className
)}
{...props}
@@ -34,7 +37,29 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
// Positioning
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
// Size
'w-[calc(100%-2rem)] max-w-lg',
// Glassmorphism effect
'bg-background/80 backdrop-blur-xl',
'border border-border/50',
'shadow-2xl shadow-black/20',
// Ring glow effect
'ring-1 ring-white/10',
// Layout
'grid gap-5 p-6 sm:p-7',
// Shape
'rounded-xl sm:rounded-2xl',
// Animations
'duration-300 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
// Focus
'outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className
)}
{...props}
@@ -44,13 +69,24 @@ const AlertDialogContent = React.forwardRef<
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
<div
className={cn(
'flex flex-col space-y-2',
'text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3',
'pt-2',
className
)}
{...props}
/>
);
@@ -62,7 +98,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
className={cn(
'text-xl font-semibold leading-tight tracking-tight',
'text-foreground',
className
)}
{...props}
/>
));
@@ -74,7 +114,11 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn(
'text-sm leading-relaxed',
'text-muted-foreground',
className
)}
{...props}
/>
));
@@ -84,7 +128,11 @@ const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), 'transition-all duration-200', className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
@@ -94,7 +142,11 @@ const AlertDialogCancel = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
className={cn(
buttonVariants({ variant: 'outline' }),
'transition-all duration-200',
className
)}
{...props}
/>
));
+73 -9
View File
@@ -19,7 +19,10 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-300 ease-out',
className
)}
{...props}
@@ -36,14 +39,47 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border-0 bg-background p-6 shadow-lg duration-200 outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
// Positioning
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
// Size
'w-[calc(100%-2rem)] max-w-lg',
// Glassmorphism effect
'bg-background/80 backdrop-blur-xl',
'border border-border/50',
'shadow-2xl shadow-black/20',
// Ring glow effect
'ring-1 ring-white/10',
// Layout
'grid gap-5 p-6 sm:p-7',
// Shape
'rounded-xl sm:rounded-2xl',
// Animations
'duration-300 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
// Focus
'outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-all hover:opacity-100 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-6 w-6" />
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 sm:right-5 sm:top-5',
'rounded-full p-1.5',
'text-muted-foreground/70 hover:text-foreground',
'bg-transparent hover:bg-muted/80',
'transition-all duration-200 ease-out',
'hover:scale-110 active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none'
)}
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@@ -52,20 +88,40 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
<div
className={cn(
'flex flex-col space-y-2',
'text-center sm:text-left',
'pr-8', // Space for close button
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3',
'pt-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('-mx-4 max-h-[80vh] overflow-y-auto px-4', className)} {...props} />
<div
className={cn(
'-mx-6 sm:-mx-7 px-6 sm:px-7',
'max-h-[60vh] overflow-y-auto',
'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/20',
className
)}
{...props}
/>
);
DialogBody.displayName = 'DialogBody';
@@ -75,7 +131,11 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
className={cn(
'text-xl font-semibold leading-tight tracking-tight',
'text-foreground',
className
)}
{...props}
/>
));
@@ -87,7 +147,11 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn(
'text-sm leading-relaxed',
'text-muted-foreground',
className
)}
{...props}
/>
));
+76 -12
View File
@@ -25,14 +25,18 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'flex cursor-default select-none items-center gap-2',
'rounded-lg px-2.5 py-2 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent data-[state=open]:bg-accent',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
<ChevronRight className="ml-auto opacity-60" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@@ -44,7 +48,28 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-primary/50 bg-popover p-1 text-popover-foreground shadow-lg outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
'z-50 min-w-[8rem] overflow-hidden',
// Glassmorphism
'bg-popover/95 backdrop-blur-xl',
'border border-border/50',
'shadow-xl shadow-black/10',
'ring-1 ring-white/5',
// Shape
'rounded-xl p-1.5',
// Text
'text-popover-foreground',
// Focus
'outline-none',
// Animations
'duration-200 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'origin-[--radix-dropdown-menu-content-transform-origin]',
className
)}
{...props}
@@ -61,7 +86,29 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-primary/50 bg-popover p-1 text-popover-foreground shadow-md outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
'z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden',
'max-h-[var(--radix-dropdown-menu-content-available-height)]',
// Glassmorphism
'bg-popover/95 backdrop-blur-xl',
'border border-border/50',
'shadow-xl shadow-black/10',
'ring-1 ring-white/5',
// Shape
'rounded-xl p-1.5',
// Text
'text-popover-foreground',
// Focus
'outline-none',
// Animations
'duration-200 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'origin-[--radix-dropdown-menu-content-transform-origin]',
className
)}
{...props}
@@ -79,7 +126,12 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex cursor-default select-none items-center gap-2',
'rounded-lg px-2.5 py-2 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
@@ -95,13 +147,17 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center',
'rounded-lg py-2 pl-8 pr-2.5 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -118,12 +174,16 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center',
'rounded-lg py-2 pl-8 pr-2.5 text-sm',
'outline-none transition-colors duration-150',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -141,7 +201,11 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
className={cn(
'px-2.5 py-2 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
@@ -153,7 +217,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
className={cn('-mx-1 my-1.5 h-px bg-border/50', className)}
{...props}
/>
));
@@ -161,7 +225,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
<span className={cn('ml-auto text-xs tracking-widest opacity-50', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
+22 -1
View File
@@ -18,7 +18,28 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'z-[100] w-72 rounded-md border border-primary/50 bg-popover p-4 text-popover-foreground shadow-md outline-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]',
'z-[100] w-72',
// Glassmorphism
'bg-popover/95 backdrop-blur-xl',
'border border-border/50',
'shadow-xl shadow-black/10',
'ring-1 ring-white/5',
// Shape
'rounded-xl p-4',
// Text
'text-popover-foreground',
// Focus
'outline-none',
// Animations
'duration-200 ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
'origin-[--radix-popover-content-transform-origin]',
className
)}
{...props}
+73 -14
View File
@@ -19,7 +19,10 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-300 ease-out',
className
)}
{...props}
@@ -29,16 +32,41 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
cn(
'fixed z-50 gap-5',
// Glassmorphism
'bg-background/90 backdrop-blur-xl',
'shadow-2xl shadow-black/20',
// Animation base
'transition-all ease-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:duration-300 data-[state=open]:duration-300'
),
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
top: cn(
'inset-x-0 top-0 p-6',
'border-b border-border/50',
'rounded-b-2xl',
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top'
),
bottom: cn(
'inset-x-0 bottom-0 p-6',
'border-t border-border/50',
'rounded-t-2xl',
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom'
),
left: cn(
'inset-y-0 left-0 h-full w-[85%] sm:w-3/4 sm:max-w-sm p-6',
'border-r border-border/50',
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left'
),
right: cn(
'inset-y-0 right-0 h-full w-[85%] sm:w-3/4 sm:max-w-sm p-6',
'border-l border-border/50',
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right'
),
},
},
defaultVariants: {
@@ -63,8 +91,19 @@ const SheetContent = React.forwardRef<
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<SheetPrimitive.Close
className={cn(
'absolute right-4 top-4',
'rounded-full p-1.5',
'text-muted-foreground/70 hover:text-foreground',
'bg-transparent hover:bg-muted/80',
'transition-all duration-200 ease-out',
'hover:scale-110 active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none'
)}
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
@@ -73,13 +112,25 @@ const SheetContent = React.forwardRef<
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
<div
className={cn(
'flex flex-col space-y-2',
'text-center sm:text-left',
'pr-8', // Space for close button
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3',
'pt-2',
className
)}
{...props}
/>
);
@@ -91,7 +142,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
className={cn(
'text-xl font-semibold leading-tight tracking-tight',
'text-foreground',
className
)}
{...props}
/>
));
@@ -103,7 +158,11 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn(
'text-sm leading-relaxed',
'text-muted-foreground',
className
)}
{...props}
/>
));
+3 -3
View File
@@ -29,10 +29,10 @@ export function useRelatedGames(id: string, limit = 10) {
});
}
export function useRandomGame(library?: string) {
export function useRandomGame(library?: string, platforms?: string[]) {
return useQuery({
queryKey: ['games', 'random', library],
queryFn: () => gamesApi.getRandom(library),
queryKey: ['games', 'random', library, platforms],
queryFn: () => gamesApi.getRandom(library, platforms),
});
}
+5 -2
View File
@@ -33,9 +33,12 @@ export const gamesApi = {
return data;
},
getRandom: async (library?: string): Promise<Game> => {
getRandom: async (library?: string, platforms?: string[]): Promise<Game> => {
const { data } = await apiClient.get<Game>('/games/random', {
params: { library },
params: {
library,
platforms: platforms?.join(','),
},
});
return data;
},
+8 -4
View File
@@ -59,15 +59,19 @@ export const SECTION_CONFIGS: Record<string, SectionConfig> = {
};
/**
* Map of sidebar paths to URL prefixes for active state detection
* Used by SidebarItem to determine if a section-based game route
* should highlight the corresponding sidebar item
* 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/ falls back to browse section
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
'/playlists': ['/playlists/'],
'/flashpoint-playlists': ['/flashpoint-playlists/'],
'/favorites': ['/favorites/'],
};
/**
-3
View File
@@ -59,7 +59,6 @@ export interface Role {
id: number;
name: string;
description: string;
priority: number;
createdAt: string;
updatedAt: string;
permissions: Permission[];
@@ -68,14 +67,12 @@ export interface Role {
export interface CreateRoleData {
name: string;
description?: string;
priority?: number;
permissionIds?: number[];
}
export interface UpdateRoleData {
name?: string;
description?: string;
priority?: number;
}
export interface CreateUserData {
+10 -2
View File
@@ -129,14 +129,22 @@ export function FavoritesView() {
games={games}
favoriteGameIds={favoriteGameIds}
isFavoritePage={true}
breadcrumbContext={{ label: 'Favorites', href: '/favorites' }}
breadcrumbContext={{
label: 'Favorites',
href: '/favorites',
icon: { type: 'lucide', value: 'Heart' },
}}
/>
) : (
<GameList
games={games}
favoriteGameIds={favoriteGameIds}
isFavoritePage={true}
breadcrumbContext={{ label: 'Favorites', href: '/favorites' }}
breadcrumbContext={{
label: 'Favorites',
href: '/favorites',
icon: { type: 'lucide', value: 'Heart' },
}}
/>
)}
</>
+31 -12
View File
@@ -204,18 +204,37 @@ 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 }]
: [
{ label: breadcrumbContext.label, href: breadcrumbContext.href, icon: breadcrumbContext.icon },
{ 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 = [];
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>
+8
View File
@@ -85,6 +85,14 @@ 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,
+21 -9
View File
@@ -2,10 +2,10 @@ import { useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { usePlaylist } from '@/hooks/usePlaylists';
import { useFavoriteGameIds } from '@/hooks/useFavorites';
import { ArrowLeft } from 'lucide-react';
import { GameGrid } from '@/components/library/GameGrid';
import { Badge } from '@/components/ui/badge';
import { useAuthStore } from '@/store/auth';
import { Breadcrumbs } from '@/components/common/Breadcrumbs';
export function PlaylistDetailView() {
const { id } = useParams<{ id: string }>();
@@ -40,13 +40,17 @@ export function PlaylistDetailView() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<Link
to="/flashpoint-playlists"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={20} />
Back to playlists
</Link>
<Breadcrumbs
items={[
{
label: 'Flashpoint Playlists',
href: '/flashpoint-playlists',
icon: { type: 'lucide', value: 'List' },
},
{ label: playlist.title, active: true },
]}
fallbackHref="/flashpoint-playlists"
/>
<div>
<div className="flex items-center gap-3 mb-2">
@@ -69,7 +73,15 @@ export function PlaylistDetailView() {
<GameGrid
games={playlist.games}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={{ label: playlist.title, href: `/flashpoint-playlists/${id ?? ''}` }}
breadcrumbContext={{
label: playlist.title,
href: `/flashpoint-playlists/${id ?? ''}`,
parent: {
label: 'Flashpoint Playlists',
href: '/flashpoint-playlists',
icon: { type: 'lucide', value: 'List' },
},
}}
/>
</>
) : (
+31 -10
View File
@@ -6,7 +6,8 @@ import {
useDeleteUserPlaylist,
} from '@/hooks/useUserPlaylists';
import { useFavoriteGameIds } from '@/hooks/useFavorites';
import { ArrowLeft, Edit, Trash2, MoreVertical, Share2 } from 'lucide-react';
import { Edit, Trash2, MoreVertical, Share2 } from 'lucide-react';
import { Breadcrumbs } from '@/components/common/Breadcrumbs';
import { GameGrid } from '@/components/library/GameGrid';
import { GameList } from '@/components/library/GameList';
import { Button } from '@/components/ui/button';
@@ -101,13 +102,17 @@ export function UserPlaylistDetailView() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<Link
to="/playlists"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={20} />
Back to playlists
</Link>
<Breadcrumbs
items={[
{
label: 'My Playlists',
href: '/playlists',
icon: { type: 'lucide', value: 'ListVideo' },
},
{ label: playlist.title, active: true },
]}
fallbackHref="/playlists"
/>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex gap-4">
@@ -181,13 +186,29 @@ export function UserPlaylistDetailView() {
<GameGrid
games={playlistGames}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={{ label: playlist.title, href: `/playlists/${playlist.id}` }}
breadcrumbContext={{
label: playlist.title,
href: `/playlists/${playlist.id}`,
parent: {
label: 'My Playlists',
href: '/playlists',
icon: { type: 'lucide', value: 'ListVideo' },
},
}}
/>
) : (
<GameList
games={playlistGames}
favoriteGameIds={isAuthenticated ? favoriteGameIds : undefined}
breadcrumbContext={{ label: playlist.title, href: `/playlists/${playlist.id}` }}
breadcrumbContext={{
label: playlist.title,
href: `/playlists/${playlist.id}`,
parent: {
label: 'My Playlists',
href: '/playlists',
icon: { type: 'lucide', value: 'ListVideo' },
},
}}
/>
)}
</>