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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '/' } },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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/'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user