feat: review and polish playlists feature

This commit is contained in:
Darkraise
2026-01-29 12:24:52 +07:00
parent 80a506bf3e
commit cd485fb1b8
33 changed files with 3472 additions and 352 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ export const authenticate = async (req: Request, res: Response, next: NextFuncti
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AppError(401, 'No token provided');
throw new AppError(401, 'No token provided', true, 'NO_TOKEN');
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
@@ -44,7 +44,7 @@ export const optionalAuth = async (req: Request, res: Response, next: NextFuncti
} else {
// No token - check if guest access is enabled
if (!authService.isGuestAccessEnabled()) {
throw new AppError(401, 'Authentication required');
throw new AppError(401, 'Authentication required', true, 'AUTH_REQUIRED');
}
// Set guest user with read-only permissions
+60
View File
@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from 'express';
/**
* Date Range Validation Middleware
*
* Validates that startDate and endDate query parameters are valid dates
* and that endDate is not before startDate.
*
* @param startDateKey - Query parameter name for start date (default: 'startDate')
* @param endDateKey - Query parameter name for end date (default: 'endDate')
* @returns Express middleware function
*
* @example
* ```typescript
* router.get('/activities',
* validateDateRange(),
* async (req, res) => {
* // Date range is validated
* }
* );
*
* // With custom parameter names
* router.get('/reports',
* validateDateRange('from', 'to'),
* async (req, res) => {
* // Date range is validated using 'from' and 'to' params
* }
* );
* ```
*/
export function validateDateRange(
startDateKey: string = 'startDate',
endDateKey: string = 'endDate'
) {
return (req: Request, res: Response, next: NextFunction) => {
const startDate = req.query[startDateKey] as string | undefined;
const endDate = req.query[endDateKey] as string | undefined;
if (startDate && endDate) {
const startDateObj = new Date(startDate);
const endDateObj = new Date(endDate);
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
return res.status(400).json({
success: false,
error: 'Invalid date format'
});
}
if (endDateObj < startDateObj) {
return res.status(400).json({
success: false,
error: 'End date must be after or equal to start date'
});
}
}
next();
};
}
+4 -2
View File
@@ -5,7 +5,8 @@ export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
public isOperational = true,
public code?: string
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
@@ -58,7 +59,8 @@ export function errorHandler(
return res.status(err.statusCode).json({
error: {
message: err.message,
statusCode: err.statusCode
statusCode: err.statusCode,
...(err.code && { code: err.code })
}
});
}
+20 -28
View File
@@ -5,14 +5,25 @@ import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { requireFeature } from '../middleware/featureFlags';
import { asyncHandler } from '../middleware/asyncHandler';
import { validateDateRange } from '../middleware/dateValidation';
const router = Router();
const activityService = new ActivityService();
// Pagination and limit constants
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
const MAX_PAGE_LIMIT = 100;
const MAX_ACTIONS_LIMIT = 50;
const MAX_BREAKDOWN_LIMIT = 50;
const DEFAULT_TREND_DAYS = 7;
const MAX_TREND_DAYS = 30;
const DEFAULT_TIME_RANGE = '24h' as const;
// Validation schema for activity query parameters
const activityQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
page: z.coerce.number().int().min(1).default(DEFAULT_PAGE),
limit: z.coerce.number().int().min(1).max(MAX_PAGE_LIMIT).default(DEFAULT_LIMIT),
userId: z.coerce.number().int().optional(),
username: z.string().optional(),
action: z.string().optional(),
@@ -34,6 +45,7 @@ router.get(
'/',
authenticate,
requirePermission('activities.read'),
validateDateRange(),
asyncHandler(async (req, res) => {
// Validate and parse query parameters
const queryResult = activityQuerySchema.safeParse(req.query);
@@ -57,26 +69,6 @@ router.get(
if (startDate) filters.startDate = startDate;
if (endDate) filters.endDate = endDate;
// Validate date range
if (filters.startDate && filters.endDate) {
const startDateObj = new Date(filters.startDate);
const endDateObj = new Date(filters.endDate);
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
return res.status(400).json({
success: false,
error: 'Invalid date format'
});
}
if (endDateObj < startDateObj) {
return res.status(400).json({
success: false,
error: 'End date must be after or equal to start date'
});
}
}
const result = await activityService.getLogs(page, limit, filters, sortBy, sortOrder);
res.json(result);
@@ -92,7 +84,7 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || '24h';
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
const customRange = req.query.startDate && req.query.endDate
? {
startDate: req.query.startDate as string,
@@ -122,7 +114,7 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const days = Math.min(parseInt(req.query.days as string) || 7, 30);
const days = Math.min(parseInt(req.query.days as string) || DEFAULT_TREND_DAYS, MAX_TREND_DAYS);
const result = await activityService.getTrend(days);
@@ -142,8 +134,8 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || '24h';
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_ACTIONS_LIMIT);
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
const result = await activityService.getTopActions(limit, timeRange);
@@ -164,8 +156,8 @@ router.get(
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const groupBy = (req.query.groupBy as 'resource' | 'user' | 'ip') || 'resource';
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || '24h';
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_BREAKDOWN_LIMIT);
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
// Validate groupBy parameter
if (!['resource', 'user', 'ip'].includes(groupBy)) {
+4 -5
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { FavoritesService } from '../services/FavoritesService';
import { authenticate, softAuth } from '../middleware/auth';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { requireFeature } from '../middleware/featureFlags';
import { logActivity } from '../middleware/activityLogger';
@@ -11,12 +11,11 @@ import { z } from 'zod';
const router = Router();
const favoritesService = new FavoritesService();
// Apply soft auth first so admins can bypass feature flags
// softAuth populates req.user if token exists, but never throws errors
router.use(softAuth);
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enableFavorites'));
// Validation schemas
+4 -5
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import { PlaylistService, CreatePlaylistDto, AddGamesToPlaylistDto } from '../services/PlaylistService';
import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { authenticate, softAuth } from '../middleware/auth';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { requireFeature } from '../middleware/featureFlags';
import { logActivity } from '../middleware/activityLogger';
@@ -10,12 +10,11 @@ import { logActivity } from '../middleware/activityLogger';
const router = Router();
const playlistService = new PlaylistService();
// Apply soft auth first so admins can bypass feature flags
// softAuth populates req.user if token exists, but never throws errors
router.use(softAuth);
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enablePlaylists'));
// GET /api/playlists - List all playlists
+3 -5
View File
@@ -1,19 +1,17 @@
import { Router } from 'express';
import { StatisticsService } from '../services/StatisticsService';
import { requireFeature } from '../middleware/featureFlags';
import { softAuth } from '../middleware/auth';
import { asyncHandler } from '../middleware/asyncHandler';
import { logger } from '../utils/logger';
const router = Router();
const statisticsService = new StatisticsService();
// Apply soft auth first so admins can bypass feature flags
// softAuth populates req.user if token exists, but never throws errors
router.use(softAuth);
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enableStatistics'));
/**
+4 -5
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { UserPlaylistService } from '../services/UserPlaylistService';
import { authenticate, softAuth } from '../middleware/auth';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { requireFeature } from '../middleware/featureFlags';
import { logActivity } from '../middleware/activityLogger';
@@ -11,12 +11,11 @@ import { z } from 'zod';
const router = Router();
const playlistService = new UserPlaylistService();
// Apply soft auth first so admins can bypass feature flags
// softAuth populates req.user if token exists, but never throws errors
router.use(softAuth);
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enablePlaylists'));
// Validation schemas
+80 -106
View File
@@ -2,8 +2,69 @@ import { UserDatabaseService } from './UserDatabaseService';
import { ActivityLog, LogActivityData } from '../types/auth';
import { PaginatedResponse, createPaginatedResponse, calculateOffset } from '../utils/pagination';
import { ActivityBreakdownRow, UserBreakdownRow, ResourceBreakdownRow, IpBreakdownRow } from '../types/database-rows';
import { TimeRange, getHoursFromTimeRange } from '../utils/timeRangeUtils';
import { AUTH_ACTIONS, categorizeAction } from '../utils/activityUtils';
/**
* Generate SQL IN clause from AUTH_ACTIONS constant.
* Keeps the constant as the single source of truth for auth action strings.
*/
const AUTH_ACTIONS_SQL = `'${AUTH_ACTIONS.join("', '")}'`;
interface ActivityFilters {
userId?: number;
username?: string;
action?: string;
resource?: string;
startDate?: string;
endDate?: string;
}
export class ActivityService {
/**
* Build WHERE clause from filters for reuse across queries
*/
private buildWhereClause(filters?: ActivityFilters): { sql: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (filters?.userId !== undefined) {
conditions.push('user_id = ?');
params.push(filters.userId);
}
if (filters?.username) {
conditions.push('username LIKE ?');
params.push(`%${filters.username}%`);
}
if (filters?.action) {
conditions.push('action LIKE ?');
params.push(`${filters.action}%`);
}
if (filters?.resource) {
conditions.push('resource LIKE ?');
params.push(`${filters.resource}%`);
}
if (filters?.startDate) {
conditions.push('created_at >= ?');
params.push(filters.startDate);
}
if (filters?.endDate) {
conditions.push('created_at <= ?');
params.push(filters.endDate);
}
const sql = conditions.length > 0
? 'WHERE ' + conditions.join(' AND ')
: '';
return { sql, params };
}
/**
* Log activity
*/
@@ -33,20 +94,12 @@ export class ActivityService {
async getLogs(
page: number = 1,
limit: number = 50,
filters?: {
userId?: number;
username?: string;
action?: string;
resource?: string;
startDate?: string;
endDate?: string;
},
filters?: ActivityFilters,
sortBy: string = 'createdAt',
sortOrder: 'asc' | 'desc' = 'desc'
): Promise<PaginatedResponse<ActivityLog>> {
const offset = calculateOffset(page, limit);
// Map frontend column names to database column names
const columnMap: Record<string, string> = {
createdAt: 'created_at',
username: 'username',
@@ -57,88 +110,24 @@ export class ActivityService {
const dbColumn = columnMap[sortBy] || 'created_at';
const order = sortOrder.toUpperCase();
let sql = `
const { sql: whereClause, params: whereParams } = this.buildWhereClause(filters);
const sql = `
SELECT id, user_id as userId, username, action, resource, resource_id as resourceId,
details, ip_address as ipAddress, user_agent as userAgent, created_at as createdAt
FROM activity_logs
WHERE 1=1
${whereClause}
ORDER BY ${dbColumn} ${order}
LIMIT ? OFFSET ?
`;
const params: any[] = [];
if (filters?.userId) {
sql += ' AND user_id = ?';
params.push(filters.userId);
}
if (filters?.username) {
sql += ' AND username LIKE ?';
params.push(`%${filters.username}%`);
}
if (filters?.action) {
sql += ' AND action LIKE ?';
params.push(`${filters.action}%`);
}
if (filters?.resource) {
sql += ' AND resource LIKE ?';
params.push(`${filters.resource}%`);
}
if (filters?.startDate) {
sql += ' AND created_at >= ?';
params.push(filters.startDate);
}
if (filters?.endDate) {
sql += ' AND created_at <= ?';
params.push(filters.endDate);
}
sql += ` ORDER BY ${dbColumn} ${order} LIMIT ? OFFSET ?`;
params.push(limit, offset);
const logs = UserDatabaseService.all<ActivityLog>(sql, params).map(log => ({
const logs = UserDatabaseService.all<ActivityLog>(sql, [...whereParams, limit, offset]).map(log => ({
...log,
details: log.details ? JSON.parse(log.details as string) : null
}));
// Count total
let countSql = 'SELECT COUNT(*) as count FROM activity_logs WHERE 1=1';
const countParams: any[] = [];
if (filters?.userId) {
countSql += ' AND user_id = ?';
countParams.push(filters.userId);
}
if (filters?.username) {
countSql += ' AND username LIKE ?';
countParams.push(`%${filters.username}%`);
}
if (filters?.action) {
countSql += ' AND action LIKE ?';
countParams.push(`${filters.action}%`);
}
if (filters?.resource) {
countSql += ' AND resource LIKE ?';
countParams.push(`${filters.resource}%`);
}
if (filters?.startDate) {
countSql += ' AND created_at >= ?';
countParams.push(filters.startDate);
}
if (filters?.endDate) {
countSql += ' AND created_at <= ?';
countParams.push(filters.endDate);
}
const total = UserDatabaseService.get<{ count: number }>(countSql, countParams)?.count || 0;
const countSql = `SELECT COUNT(*) as count FROM activity_logs ${whereClause}`;
const total = UserDatabaseService.get<{ count: number }>(countSql, whereParams)?.count || 0;
return createPaginatedResponse(logs, total, page, limit);
}
@@ -157,8 +146,7 @@ export class ActivityService {
/**
* Get aggregate statistics for dashboard
*/
async getStats(timeRange: '24h' | '7d' | '30d' = '24h', customRange?: { startDate?: string; endDate?: string }) {
// Calculate time window
async getStats(timeRange: TimeRange = '24h', customRange?: { startDate?: string; endDate?: string }) {
let startDate: string;
let endDate: string = new Date().toISOString();
@@ -166,7 +154,7 @@ export class ActivityService {
startDate = customRange.startDate;
endDate = customRange.endDate;
} else {
const hours = timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720; // 24h, 7d, 30d
const hours = getHoursFromTimeRange(timeRange);
startDate = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
}
@@ -183,7 +171,7 @@ export class ActivityService {
`SELECT
COUNT(*) as total,
COUNT(DISTINCT user_id) as uniqueUsers,
COUNT(CASE WHEN action IN ('login', 'logout', 'register', 'auth.login.failed') THEN 1 END) as authEvents,
COUNT(CASE WHEN action IN (${AUTH_ACTIONS_SQL}) THEN 1 END) as authEvents,
COUNT(CASE WHEN action IN ('login', 'register') THEN 1 END) as authSuccessful,
COUNT(CASE WHEN action = 'auth.login.failed' THEN 1 END) as authFailed,
COUNT(CASE WHEN action LIKE '%fail%' OR action LIKE '%error%' THEN 1 END) as failedOperations,
@@ -212,7 +200,7 @@ export class ActivityService {
`SELECT
COUNT(*) as total,
COUNT(DISTINCT user_id) as uniqueUsers,
COUNT(CASE WHEN action IN ('login', 'logout', 'register', 'auth.login.failed') THEN 1 END) as authEvents
COUNT(CASE WHEN action IN (${AUTH_ACTIONS_SQL}) THEN 1 END) as authEvents
FROM activity_logs
WHERE created_at >= ? AND created_at < ?`,
[previousPeriodStart, startDate]
@@ -283,7 +271,7 @@ export class ActivityService {
`SELECT
${groupFormat} as timestamp,
COUNT(*) as total,
COUNT(CASE WHEN action IN ('login', 'logout', 'register', 'auth.login.failed') THEN 1 END) as authEvents,
COUNT(CASE WHEN action IN (${AUTH_ACTIONS_SQL}) THEN 1 END) as authEvents,
COUNT(CASE WHEN action LIKE '%fail%' OR action LIKE '%error%' THEN 1 END) as failedActions,
COUNT(DISTINCT user_id) as uniqueUsers
FROM activity_logs
@@ -313,8 +301,8 @@ export class ActivityService {
/**
* Get top actions by frequency
*/
async getTopActions(limit: number = 10, timeRange: '24h' | '7d' | '30d' = '24h') {
const hours = timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720;
async getTopActions(limit: number = 10, timeRange: TimeRange = '24h') {
const hours = getHoursFromTimeRange(timeRange);
const startDate = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
// Get total count for percentage calculation
@@ -364,20 +352,6 @@ export class ActivityService {
exampleTimestamp: string;
}>;
// Categorize actions
const categorizeAction = (action: string): 'auth' | 'crud' | 'error' | 'system' => {
if (action.includes('auth') || action.includes('login') || action.includes('logout') || action.includes('register')) {
return 'auth';
}
if (action.includes('fail') || action.includes('error')) {
return 'error';
}
if (action.includes('create') || action.includes('update') || action.includes('delete')) {
return 'crud';
}
return 'system';
};
return {
data: topActions.map(action => ({
action: action.action,
@@ -403,9 +377,9 @@ export class ActivityService {
async getBreakdown(
groupBy: 'resource' | 'user' | 'ip',
limit: number = 10,
timeRange: '24h' | '7d' | '30d' = '24h'
timeRange: TimeRange = '24h'
) {
const hours = timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720;
const hours = getHoursFromTimeRange(timeRange);
const startDate = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
// Get total count for percentage calculation
+15 -2
View File
@@ -39,6 +39,19 @@ export class UserPlaylistService {
private userDb: typeof UserDatabaseService;
private gameService: GameService;
// Reusable SELECT columns with aliases for type consistency
private static readonly PLAYLIST_COLUMNS = `
id,
user_id AS userId,
title,
description,
icon,
created_at AS createdAt,
updated_at AS updatedAt,
is_public AS isPublic,
game_count AS gameCount
`.trim();
constructor() {
this.userDb = UserDatabaseService;
this.gameService = new GameService();
@@ -51,7 +64,7 @@ export class UserPlaylistService {
const db = this.userDb.getDatabase();
const stmt = db.prepare(`
SELECT *
SELECT ${UserPlaylistService.PLAYLIST_COLUMNS}
FROM user_playlists
WHERE user_id = ?
ORDER BY updated_at DESC
@@ -67,7 +80,7 @@ export class UserPlaylistService {
const db = this.userDb.getDatabase();
const stmt = db.prepare(`
SELECT *
SELECT ${UserPlaylistService.PLAYLIST_COLUMNS}
FROM user_playlists
WHERE id = ? AND user_id = ?
`);
+40
View File
@@ -0,0 +1,40 @@
export type ActionCategory = 'auth' | 'crud' | 'error' | 'system';
/**
* Authentication-related actions used in SQL queries and analytics.
* Used for aggregating auth events in getStats(), getTrend(), and getPreviousPeriodStats().
*/
export const AUTH_ACTIONS = [
'login',
'logout',
'register',
'auth.login.failed'
] as const;
export type AuthAction = typeof AUTH_ACTIONS[number];
/**
* Categorize an action string into one of four categories.
* This logic is shared between frontend (badge styling) and backend (analytics).
*/
export function categorizeAction(action: string): ActionCategory {
if (action.includes('fail') || action.includes('error')) {
return 'error';
}
if (
action.startsWith('auth') ||
action === 'login' ||
action === 'logout' ||
action === 'register'
) {
return 'auth';
}
if (
action.includes('schedule') ||
action.includes('cleanup') ||
action.includes('maintenance')
) {
return 'system';
}
return 'crud';
}
+23
View File
@@ -0,0 +1,23 @@
export type TimeRange = '24h' | '7d' | '30d';
export function getHoursFromTimeRange(timeRange: TimeRange): number {
switch (timeRange) {
case '24h':
return 24;
case '7d':
return 168;
case '30d':
return 720;
}
}
export function getStartDateFromTimeRange(timeRange: TimeRange): Date {
const hours = getHoursFromTimeRange(timeRange);
const date = new Date();
date.setHours(date.getHours() - hours);
return date;
}
export function getEndDateFromTimeRange(): Date {
return new Date();
}
BIN
View File
Binary file not shown.
+1
View File
@@ -35,6 +35,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.28.9",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
@@ -1,15 +1,17 @@
import { useState, useEffect, memo, useMemo, useCallback, useRef, useTransition } from 'react';
import { useState, memo, useMemo, useCallback, useTransition } from 'react';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { ChevronDown, AlertCircle } from 'lucide-react';
import { useActivities } from '../../hooks/useActivities';
import { useActivityFilters } from '../../hooks/useActivityFilters';
import { useDebounce } from '../../hooks/useDebounce';
import { ActivityFilters } from '../../types/auth';
import { getApiErrorMessage } from '@/utils/errorUtils';
import { getActionBadgeVariant } from '@/utils/activityUtils';
import { DataTable } from '../ui/data-table';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { FilterInput } from './FilterInput';
import { DatePicker } from '../ui/date-picker';
import { FormattedDate } from '../common/FormattedDate';
import {
@@ -39,17 +41,6 @@ interface ActivityLog {
const PAGINATION_LIMIT = 20;
const DEBOUNCE_DELAY = 500;
// Helper to get badge variant based on action type
const getActionBadgeVariant = (action: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
if (action.includes('fail') || action.includes('error')) {
return 'destructive';
}
if (action.startsWith('auth') || action === 'login' || action === 'logout' || action === 'register') {
return 'default';
}
return 'secondary';
};
// Columns definition - moved outside component to prevent recreation on every render
const ACTIVITY_TABLE_COLUMNS: ColumnDef<ActivityLog>[] = [
{
@@ -156,51 +147,29 @@ function ActivityTableComponent() {
const [sortBy, setSortBy] = useState<'createdAt' | 'username' | 'action' | 'resource' | 'ipAddress'>('createdAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Ref to track current sorting state without closure issues
const sortingStateRef = useRef<SortingState>([{ id: 'createdAt', desc: true }]);
// Transition for deferred state updates
const [, startTransition] = useTransition();
// Immediate input values (not debounced)
const [usernameInput, setUsernameInput] = useState('');
const [actionInput, setActionInput] = useState('');
const [resourceInput, setResourceInput] = useState('');
const [startDateInput, setStartDateInput] = useState<Date | undefined>(undefined);
const [endDateInput, setEndDateInput] = useState<Date | undefined>(undefined);
const [dateRangeError, setDateRangeError] = useState<string>('');
// Consolidated filter state with date range validation
const { filters: filterState, updateFilter, clearFilters } = useActivityFilters();
// Debounced values for API calls
const debouncedUsername = useDebounce(usernameInput, DEBOUNCE_DELAY);
const debouncedAction = useDebounce(actionInput, DEBOUNCE_DELAY);
const debouncedResource = useDebounce(resourceInput, DEBOUNCE_DELAY);
// Validate date range
useEffect(() => {
if (startDateInput && endDateInput) {
if (endDateInput < startDateInput) {
setDateRangeError('End date must be after or equal to start date');
} else {
setDateRangeError('');
}
} else {
setDateRangeError('');
}
}, [startDateInput, endDateInput]);
const debouncedUsername = useDebounce(filterState.username, DEBOUNCE_DELAY);
const debouncedAction = useDebounce(filterState.action, DEBOUNCE_DELAY);
const debouncedResource = useDebounce(filterState.resource, DEBOUNCE_DELAY);
// Build filters object - convert Date objects to ISO strings for API
// Only include dates if the range is valid
const hasValidDateRange = !dateRangeError || (!startDateInput && !endDateInput);
const hasValidDateRange = !filterState.dateRangeError || (!filterState.startDate && !filterState.endDate);
const filters: ActivityFilters = useMemo(() => ({
username: debouncedUsername || undefined,
action: debouncedAction || undefined,
resource: debouncedResource || undefined,
startDate: hasValidDateRange && startDateInput ? startDateInput.toISOString() : undefined,
endDate: hasValidDateRange && endDateInput ? endDateInput.toISOString() : undefined,
startDate: hasValidDateRange && filterState.startDate ? filterState.startDate.toISOString() : undefined,
endDate: hasValidDateRange && filterState.endDate ? filterState.endDate.toISOString() : undefined,
sortBy,
sortOrder,
}), [debouncedUsername, debouncedAction, debouncedResource, startDateInput, endDateInput, hasValidDateRange, sortBy, sortOrder]);
}), [debouncedUsername, debouncedAction, debouncedResource, filterState.startDate, filterState.endDate, hasValidDateRange, sortBy, sortOrder]);
const { data, isLoading, isError, error } = useActivities(page, limit, filters);
@@ -209,16 +178,10 @@ function ActivityTableComponent() {
{ id: sortBy, desc: sortOrder === 'desc' }
], [sortBy, sortOrder]);
// Keep ref in sync with sortingState
useEffect(() => {
sortingStateRef.current = sortingState;
}, [sortingState]);
// Handle sorting changes from DataTable
const handleSortingChange = useCallback((updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => {
// Use the ref to get the current state, avoiding stale closure
const newSorting = typeof updaterOrValue === 'function'
? updaterOrValue(sortingStateRef.current)
? updaterOrValue(sortingState)
: updaterOrValue;
// Wrap state updates in transition to defer prop changes
@@ -234,29 +197,24 @@ function ActivityTableComponent() {
}
setPage(1); // Reset to first page on sort change
});
}, []); // Empty dependency array - callback is stable
}, [sortingState]);
const handleClearFilters = () => {
setUsernameInput('');
setActionInput('');
setResourceInput('');
setStartDateInput(undefined);
setEndDateInput(undefined);
setDateRangeError('');
const handleClearFilters = useCallback(() => {
clearFilters();
setPage(1);
};
}, [clearFilters]);
// Check if any filters are active
const hasActiveFilters =
usernameInput || actionInput || resourceInput || startDateInput || endDateInput;
filterState.username || filterState.action || filterState.resource || filterState.startDate || filterState.endDate;
// Count active filters
const activeFilterCount = [
usernameInput,
actionInput,
resourceInput,
startDateInput,
endDateInput,
filterState.username,
filterState.action,
filterState.resource,
filterState.startDate,
filterState.endDate,
].filter(Boolean).length;
if (isError) {
@@ -298,47 +256,32 @@ function ActivityTableComponent() {
<CollapsibleContent className="mt-4">
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="username-filter">Username</Label>
<Input
id="username-filter"
type="text"
value={usernameInput}
onChange={(e) => {
setUsernameInput(e.target.value);
setPage(1);
}}
placeholder="Filter by username"
/>
</div>
<FilterInput
id="username-filter"
label="Username"
value={filterState.username}
onChange={(val) => updateFilter('username', val)}
placeholder="Filter by username"
onPageReset={() => setPage(1)}
/>
<div className="space-y-2">
<Label htmlFor="action-filter">Action (prefix match)</Label>
<Input
id="action-filter"
type="text"
value={actionInput}
onChange={(e) => {
setActionInput(e.target.value);
setPage(1);
}}
placeholder="e.g. login, auth, users"
/>
</div>
<FilterInput
id="action-filter"
label="Action (prefix match)"
value={filterState.action}
onChange={(val) => updateFilter('action', val)}
placeholder="e.g. login, auth, users"
onPageReset={() => setPage(1)}
/>
<div className="space-y-2">
<Label htmlFor="resource-filter">Resource (prefix match)</Label>
<Input
id="resource-filter"
type="text"
value={resourceInput}
onChange={(e) => {
setResourceInput(e.target.value);
setPage(1);
}}
placeholder="e.g. users, roles"
/>
</div>
<FilterInput
id="resource-filter"
label="Resource (prefix match)"
value={filterState.resource}
onChange={(val) => updateFilter('resource', val)}
placeholder="e.g. users, roles"
onPageReset={() => setPage(1)}
/>
</div>
<div className="space-y-2">
@@ -347,14 +290,14 @@ function ActivityTableComponent() {
<Label htmlFor="start-date-filter">Start Date</Label>
<DatePicker
id="start-date-filter"
date={startDateInput}
date={filterState.startDate}
onDateChange={(date) => {
setStartDateInput(date);
updateFilter('startDate', date);
setPage(1);
}}
placeholder="Filter by start date"
clearable={true}
className={dateRangeError ? 'border-destructive' : ''}
className={filterState.dateRangeError ? 'border-destructive' : ''}
/>
</div>
@@ -362,22 +305,22 @@ function ActivityTableComponent() {
<Label htmlFor="end-date-filter">End Date</Label>
<DatePicker
id="end-date-filter"
date={endDateInput}
date={filterState.endDate}
onDateChange={(date) => {
setEndDateInput(date);
updateFilter('endDate', date);
setPage(1);
}}
placeholder="Filter by end date"
clearable={true}
className={dateRangeError ? 'border-destructive' : ''}
className={filterState.dateRangeError ? 'border-destructive' : ''}
/>
</div>
</div>
{dateRangeError && (
{filterState.dateRangeError && (
<div className="text-sm text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{dateRangeError}
{filterState.dateRangeError}
</div>
)}
</div>
@@ -0,0 +1,38 @@
import { Input } from '../ui/input';
import { Label } from '../ui/label';
interface FilterInputProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
onPageReset?: () => void;
}
export function FilterInput({
id,
label,
value,
onChange,
placeholder,
onPageReset,
}: FilterInputProps): JSX.Element {
function handleChange(e: React.ChangeEvent<HTMLInputElement>): void {
onChange(e.target.value);
onPageReset?.();
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="text"
value={value}
onChange={handleChange}
placeholder={placeholder}
/>
</div>
);
}
@@ -14,6 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useCreateUserPlaylist, useUpdateUserPlaylist } from '@/hooks/useUserPlaylists';
import { UserPlaylist } from '@/types/playlist';
import { IconSelector } from './IconSelector';
import type { PlaylistIconName } from '@/lib/playlistIcons';
import { toast } from 'sonner';
import { getErrorMessage } from '@/types/api-error';
@@ -32,6 +34,7 @@ export function CreateUserPlaylistDialog({
}: CreateUserPlaylistDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [icon, setIcon] = useState<PlaylistIconName | null>(null);
const createPlaylist = useCreateUserPlaylist();
const updatePlaylist = useUpdateUserPlaylist();
@@ -43,9 +46,11 @@ export function CreateUserPlaylistDialog({
if (playlist) {
setTitle(playlist.title);
setDescription(playlist.description || '');
setIcon(playlist.icon || null);
} else {
setTitle('');
setDescription('');
setIcon(null);
}
}, [playlist, isOpen]);
@@ -66,6 +71,7 @@ export function CreateUserPlaylistDialog({
data: {
title: title.trim(),
description: description.trim() || undefined,
icon: icon || undefined,
},
});
toast.success('Playlist updated successfully');
@@ -73,6 +79,7 @@ export function CreateUserPlaylistDialog({
result = await createPlaylist.mutateAsync({
title: title.trim(),
description: description.trim() || undefined,
icon: icon || undefined,
});
toast.success('Playlist created successfully');
}
@@ -137,6 +144,18 @@ export function CreateUserPlaylistDialog({
rows={4}
/>
</div>
<div className="space-y-2">
<IconSelector
value={icon}
onChange={setIcon}
label="Icon (Optional)"
disabled={createPlaylist.isPending || updatePlaylist.isPending}
/>
<p className="text-xs text-muted-foreground">
Choose an icon to represent your playlist
</p>
</div>
</DialogBody>
<DialogFooter>
@@ -0,0 +1,195 @@
import { useState, useCallback, useMemo } from 'react';
import { X, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { PlaylistIcon } from './PlaylistIcon';
import { VirtualizedIconGrid } from './VirtualizedIconGrid';
import { ICON_CATEGORIES, type PlaylistIconName } from '@/lib/playlistIcons';
import { cn } from '@/lib/utils';
interface IconSelectorProps {
/**
* Currently selected icon name
*/
value?: PlaylistIconName | null;
/**
* Callback when icon selection changes
*/
onChange: (iconName: PlaylistIconName | null) => void;
/**
* Optional label for the selector
*/
label?: string;
/**
* Whether the selector is disabled
* @default false
*/
disabled?: boolean;
}
/**
* Enhanced icon selector with virtualized rendering
* Features: 600 icons displayed in virtualized grid, 8 columns, optimized performance
* Uses @tanstack/react-virtual for rendering only visible icons (83% reduction in DOM nodes)
*
* @example
* <IconSelector
* value={selectedIcon}
* onChange={setSelectedIcon}
* label="Playlist Icon"
* />
*/
export function IconSelector({
value,
onChange,
label = 'Select Icon',
disabled = false,
}: IconSelectorProps) {
const [open, setOpen] = useState(false);
// Flatten all icons from all categories into a single array (memoized to prevent GC pressure)
const allIcons = useMemo(
() => Object.values(ICON_CATEGORIES).flatMap(category => category.icons),
[] // ICON_CATEGORIES is static, no dependencies needed
);
// Memoized handler to select an icon and close popover
const handleIconSelect = useCallback((iconName: PlaylistIconName) => {
onChange(iconName);
setOpen(false);
}, [onChange]);
// Memoized handler to clear selection
const handleClear = useCallback(() => {
onChange(null);
setOpen(false);
}, [onChange]);
// Memoized handler for popover open/close with cleanup
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen);
// Memory leak prevention: no state to reset since virtualization handles cleanup
}, []);
// Format icon name for display (memoized)
const formatIconName = useCallback((name: string) => {
return name
.replace(/([A-Z])/g, ' $1')
.replace(/([0-9])/g, ' $1')
.trim()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}, []);
return (
<div className="space-y-2">
{label && (
<Label className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="h-3.5 w-3.5 text-primary" />
{label}
</Label>
)}
<Popover open={open} onOpenChange={handleOpenChange} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={label}
disabled={disabled}
className={cn(
'w-full justify-start gap-3 h-auto py-3 px-4',
'hover:bg-accent/50 transition-all duration-200',
'border-2 hover:border-primary/30',
value && 'border-primary/20'
)}
>
{value ? (
<>
<div className="flex items-center justify-center h-8 w-8 rounded-md bg-primary/10 border border-primary/20">
<PlaylistIcon iconName={value} size={20} className="text-primary" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">{formatIconName(value)}</span>
<span className="text-xs text-muted-foreground">Click to change</span>
</div>
</>
) : (
<>
<div className="flex items-center justify-center h-8 w-8 rounded-md border-2 border-dashed border-muted-foreground/30">
<Sparkles className="h-4 w-4 text-muted-foreground/50" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium text-muted-foreground">
No icon selected
</span>
<span className="text-xs text-muted-foreground/70">Choose from 600 icons</span>
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[560px] p-0 border-2 border-primary/20"
align="start"
sideOffset={8}
>
<div>
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b bg-muted/30">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Choose Icon
</h3>
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded-md">
{allIcons.length} icons
</span>
</div>
</div>
{/* Virtualized Icon Grid */}
<div className="relative bg-background/50">
<VirtualizedIconGrid
icons={allIcons}
selectedIcon={value}
onSelectIcon={handleIconSelect}
formatIconName={formatIconName}
/>
{/* Scroll Gradient Indicators */}
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent pointer-events-none" />
</div>
</div>
{/* Footer */}
{value && (
<div className="border-t bg-muted/30 p-3">
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className={cn(
'w-full justify-center gap-2 h-9',
'text-muted-foreground hover:text-destructive',
'hover:bg-destructive/10 transition-colors'
)}
>
<X size={16} />
Clear Selection
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ListVideo, Trash2, Edit, MoreVertical } from 'lucide-react';
import { Trash2, Edit, MoreVertical } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,6 +10,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UserPlaylist } from '@/types/playlist';
import { PlaylistIcon } from './PlaylistIcon';
interface PlaylistCardProps {
playlist: UserPlaylist;
@@ -37,29 +38,32 @@ export function PlaylistCard({
};
return (
<Card className="group overflow-hidden hover:ring-2 hover:ring-primary/80 hover:shadow-xl hover:-translate-y-1 transition-all duration-200 relative">
<Link to={`/playlists/${playlist.id}`}>
<CardContent className="p-6 flex flex-col items-center justify-center min-h-[200px] bg-gradient-to-br from-primary/10 to-accent/10 relative">
<Card className="group overflow-hidden hover:ring-2 hover:ring-primary/80 hover:shadow-xl hover:-translate-y-1 transition-all duration-200 relative flex flex-col h-full">
<Link to={`/playlists/${playlist.id}`} className="flex flex-col flex-1">
<CardContent className="p-6 flex flex-col items-center justify-center flex-1 bg-gradient-to-br from-primary/10 to-accent/10 relative">
<div className="p-4 bg-primary/20 rounded-full mb-4">
<ListVideo size={48} className="text-primary" />
<PlaylistIcon
iconName={playlist.icon}
size={48}
className="text-primary"
aria-label={`${playlist.title} icon`}
/>
</div>
<div className="text-center">
<h3 className="font-semibold text-lg mb-1" title={playlist.title}>
{playlist.title}
</h3>
{playlist.description && (
<p className="text-xs text-muted-foreground line-clamp-2 max-w-[200px]">
{playlist.description}
</p>
)}
<p className="text-xs text-muted-foreground line-clamp-2 max-w-[200px] min-h-[2rem]">
{playlist.description || '\u00A0'}
</p>
</div>
{playlist.isPublic && (
{/* {playlist.isPublic && (
<Badge variant="secondary" className="absolute top-2 left-2 text-xs">
Public
</Badge>
)}
)} */}
</CardContent>
<CardFooter className="p-3 flex items-center justify-between border-t bg-muted/30">
@@ -0,0 +1,52 @@
import { getPlaylistIcon, type PlaylistIconName } from '@/lib/playlistIcons';
import { cn } from '@/lib/utils';
interface PlaylistIconProps {
/**
* Icon name from the playlist icons registry
* If null/undefined, shows fallback icon (ListVideo)
*/
iconName?: PlaylistIconName | null;
/**
* Icon size in pixels
* @default 24
*/
size?: number;
/**
* Additional CSS classes
*/
className?: string;
/**
* Accessible label for screen readers
*/
'aria-label'?: string;
}
/**
* Displays a playlist icon by name
* Automatically handles fallback for invalid/missing icons
*
* @example
* <PlaylistIcon iconName="music" size={48} />
* <PlaylistIcon iconName={playlist.icon} size={24} className="text-primary" />
*/
export function PlaylistIcon({
iconName,
size = 24,
className,
'aria-label': ariaLabel,
}: PlaylistIconProps) {
const Icon = getPlaylistIcon(iconName);
return (
<Icon
size={size}
className={cn('flex-shrink-0', className)}
aria-hidden={!ariaLabel}
aria-label={ariaLabel}
/>
);
}
@@ -0,0 +1,189 @@
import { useRef, useCallback, memo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Check } from 'lucide-react';
import { PlaylistIcon } from './PlaylistIcon';
import { cn } from '@/lib/utils';
import type { PlaylistIconName } from '@/lib/playlistIcons';
const COLUMNS = 8;
const ROW_HEIGHT = 64; // 56px button (h-14) + 8px gap (gap-2)
const OVERSCAN = 3; // Number of rows to render outside visible area for smooth scrolling
const ICON_SIZE = 26; // Optimal icon size for 56px button with visual balance
const CHECK_SIZE = 12; // Check icon size inside selection badge
interface VirtualizedIconGridProps {
/**
* Array of icon names to display
*/
icons: readonly PlaylistIconName[];
/**
* Currently selected icon (if any)
*/
selectedIcon: PlaylistIconName | null | undefined;
/**
* Callback when an icon is selected
*/
onSelectIcon: (iconName: PlaylistIconName) => void;
/**
* Function to format icon names for display
*/
formatIconName: (name: string) => string;
}
/**
* Virtualized icon grid using @tanstack/react-virtual
* Renders only visible rows + overscan for performance with 600+ icons
*
* Performance characteristics:
* - 600 icons / 8 columns = 75 total rows
* - 400px height / 64px row height = ~6-7 visible rows
* - With overscan=3: renders ~13 rows = 104 icons (instead of all 600)
* - Reduces DOM nodes by 83% for massive performance improvement
*
* TODO: Implement keyboard navigation for grid pattern (arrow keys, roving tabIndex)
* Currently users must Tab through all icons, which is inefficient for 600 icons.
* See: https://www.w3.org/WAI/ARIA/apg/patterns/grid/
*
* @example
* <VirtualizedIconGrid
* icons={allIcons}
* selectedIcon={value}
* onSelectIcon={onChange}
* formatIconName={formatName}
* />
*/
export const VirtualizedIconGrid = memo(function VirtualizedIconGrid({
icons,
selectedIcon,
onSelectIcon,
formatIconName,
}: VirtualizedIconGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
const rowCount = Math.ceil(icons.length / COLUMNS);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
});
const getIconsForRow = useCallback(
(rowIndex: number): readonly PlaylistIconName[] => {
const startIndex = rowIndex * COLUMNS;
return icons.slice(startIndex, startIndex + COLUMNS);
},
[icons]
);
return (
<div
ref={parentRef}
className="h-[400px] overflow-auto focus:outline-none"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'hsl(var(--primary) / 0.3) transparent',
scrollbarGutter: 'stable',
}}
role="grid"
aria-label="Icon grid"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const rowIcons = getIconsForRow(virtualRow.index);
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
className="grid grid-cols-8 gap-2 px-4"
role="row"
>
{rowIcons.map((iconName) => (
<IconButton
key={iconName}
iconName={iconName}
isSelected={selectedIcon === iconName}
onSelect={onSelectIcon}
formatIconName={formatIconName}
/>
))}
</div>
);
})}
</div>
</div>
);
});
/**
* Memoized icon button to prevent unnecessary re-renders during scroll
* Only re-renders when iconName or isSelected changes
*/
const IconButton = memo(function IconButton({
iconName,
isSelected,
onSelect,
formatIconName,
}: {
iconName: PlaylistIconName;
isSelected: boolean;
onSelect: (name: PlaylistIconName) => void;
formatIconName: (name: string) => string;
}) {
const handleClick = useCallback(() => {
onSelect(iconName);
}, [iconName, onSelect]);
return (
<button
type="button"
onClick={handleClick}
className={cn(
'relative flex items-center justify-center',
'h-14 w-14 rounded-lg border-2 transition-[transform,border-color,background-color] duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
'group',
isSelected
? 'border-primary bg-primary/20 scale-105'
: 'border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:scale-105'
)}
aria-label={`Select ${formatIconName(iconName)} icon`}
aria-pressed={isSelected}
role="gridcell"
>
<PlaylistIcon
iconName={iconName}
size={ICON_SIZE}
className={cn(
'transition-all duration-200',
isSelected
? 'text-primary'
: 'text-foreground/70 group-hover:text-primary group-hover:scale-110'
)}
/>
{isSelected && (
<div className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-primary flex items-center justify-center animate-in zoom-in-50 duration-200">
<Check size={CHECK_SIZE} className="text-primary-foreground" />
</div>
)}
</button>
);
});
@@ -0,0 +1,16 @@
import { ArrowDown, ArrowUp } from 'lucide-react';
/**
* Returns the appropriate sort icon based on the current sort state.
* @param sortState - The current sorting state (false, 'asc', or 'desc')
* @returns JSX element for the sort icon, or null if not sorted
*/
export function getSortIcon(sortState: false | 'asc' | 'desc'): JSX.Element | null {
if (sortState === 'asc') {
return <ArrowUp className="h-4 w-4" aria-hidden="true" />;
}
if (sortState === 'desc') {
return <ArrowDown className="h-4 w-4" aria-hidden="true" />;
}
return null;
}
+12 -8
View File
@@ -11,7 +11,9 @@ import {
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown } from "lucide-react"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"
import { getSortIcon } from "./data-table-utils"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
@@ -60,12 +62,17 @@ export function DataTable<TData, TValue>({
// Use controlled sorting if provided, otherwise use internal state
const sorting = controlledSorting ?? internalSorting
const setSorting: (updater: SortingState | ((old: SortingState) => SortingState)) => void = React.useCallback(
(updater) => {
const setSorting = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
if (onControlledSortingChange) {
onControlledSortingChange(updater)
} else {
setInternalSorting(updater as any)
// Properly handle both function and value updaters
if (typeof updater === 'function') {
setInternalSorting((old) => updater(old))
} else {
setInternalSorting(updater)
}
}
},
[onControlledSortingChange]
@@ -150,10 +157,7 @@ export function DataTable<TData, TValue>({
}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: <ArrowUp className="h-4 w-4" aria-hidden="true" />,
desc: <ArrowDown className="h-4 w-4" aria-hidden="true" />,
}[header.column.getIsSorted() as string] ?? null}
{getSortIcon(header.column.getIsSorted())}
</div>
)}
</TableHead>
+56
View File
@@ -0,0 +1,56 @@
import { useState, useCallback, useEffect } from 'react';
export interface ActivityFilterState {
username: string;
action: string;
resource: string;
startDate: Date | undefined;
endDate: Date | undefined;
dateRangeError: string;
}
const INITIAL_FILTER_STATE: ActivityFilterState = {
username: '',
action: '',
resource: '',
startDate: undefined,
endDate: undefined,
dateRangeError: '',
};
/**
* Custom hook that manages activity filter state with date range validation.
* Consolidates 7 separate filter state variables into a single object.
*/
export function useActivityFilters() {
const [filters, setFilters] = useState<ActivityFilterState>(INITIAL_FILTER_STATE);
const updateFilter = useCallback(<K extends keyof ActivityFilterState>(
key: K,
value: ActivityFilterState[K]
) => {
setFilters(prev => ({ ...prev, [key]: value }));
}, []);
const clearFilters = useCallback(() => {
setFilters(INITIAL_FILTER_STATE);
}, []);
// Date range validation
useEffect(() => {
if (filters.startDate && filters.endDate) {
if (filters.endDate < filters.startDate) {
setFilters(prev => ({
...prev,
dateRangeError: 'End date must be after or equal to start date'
}));
} else {
setFilters(prev => ({ ...prev, dateRangeError: '' }));
}
} else {
setFilters(prev => ({ ...prev, dateRangeError: '' }));
}
}, [filters.startDate, filters.endDate]);
return { filters, updateFilter, clearFilters };
}
+121 -36
View File
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userPlaylistsApi } from '@/lib/api';
import type { PlaylistWithGames } from '@/lib/api/userPlaylists';
import {
UpdatePlaylistData,
UserPlaylist,
@@ -57,9 +58,14 @@ export function useUpdateUserPlaylist() {
mutationFn: ({ id, data }: { id: number; data: UpdatePlaylistData }) =>
userPlaylistsApi.update(id, data),
onSuccess: (updated) => {
// Update the playlists list cache
queryClient.setQueryData<UserPlaylist[]>(['user-playlists'], (old = []) => {
return old.map((playlist) => (playlist.id === updated.id ? updated : playlist));
});
// Update the single playlist detail cache
queryClient.setQueryData(['user-playlist', updated.id], updated);
showToast('Playlist updated successfully', 'success');
},
onError: (error: unknown) => {
@@ -137,37 +143,73 @@ export function useAddGamesToUserPlaylist() {
const queryClient = useQueryClient();
const { showToast } = useDialog();
return useMutation({
return useMutation<
PlaylistWithGames,
unknown,
{ id: number; gameIds: string[] },
{ previousPlaylist: unknown; previousGames: unknown; previousPlaylists: unknown }
>({
mutationFn: ({ id, gameIds }: { id: number; gameIds: string[] }) =>
userPlaylistsApi.addGames(id, gameIds),
onMutate: async ({ id, gameIds: _gameIds }) => {
onMutate: async ({ id }) => {
// Cancel outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: ['user-playlist', id] });
await queryClient.cancelQueries({ queryKey: ['user-playlist', id, 'games'] });
const previousGames = queryClient.getQueryData(['user-playlist', id, 'games']);
await queryClient.cancelQueries({ queryKey: ['user-playlists'] });
// Optimistically add games (will be verified when backend returns updated playlist)
return { previousGames };
// Snapshot previous values for rollback
const previousPlaylist = queryClient.getQueryData(['user-playlist', id]);
const previousGames = queryClient.getQueryData(['user-playlist', id, 'games']);
const previousPlaylists = queryClient.getQueryData(['user-playlists']);
return { previousPlaylist, previousGames, previousPlaylists };
},
onSuccess: (response, variables) => {
// Update single playlist cache with new gameCount
queryClient.setQueryData(['user-playlist', variables.id], {
id: response.id,
userId: response.userId,
title: response.title,
description: response.description,
icon: response.icon,
createdAt: response.createdAt,
updatedAt: response.updatedAt,
isPublic: response.isPublic,
gameCount: response.gameCount, // Updated count from backend
});
// Update games list cache
queryClient.setQueryData(['user-playlist', variables.id, 'games'], response.games);
// Update playlists list cache
queryClient.setQueryData<UserPlaylist[]>(['user-playlists'], (old = []) => {
return old.map((playlist) =>
playlist.id === variables.id
? { ...playlist, gameCount: response.gameCount }
: playlist
);
});
showToast('Games added to playlist', 'success');
},
onError: (err: unknown, variables, context) => {
if (context?.previousGames) {
queryClient.setQueryData(
['user-playlist', variables.id, 'games'],
context.previousGames
);
// Rollback all caches on error
if (context?.previousPlaylist) {
queryClient.setQueryData(['user-playlist', variables.id], context.previousPlaylist);
}
if (context?.previousGames) {
queryClient.setQueryData(['user-playlist', variables.id, 'games'], context.previousGames);
}
if (context?.previousPlaylists) {
queryClient.setQueryData(['user-playlists'], context.previousPlaylists);
}
const message = getErrorMessage(err) || 'Failed to add games to playlist';
showToast(message, 'error');
},
onSuccess: (_, variables) => {
// Invalidate queries to refetch updated data
queryClient.invalidateQueries({ queryKey: ['user-playlist', variables.id] });
queryClient.invalidateQueries({
queryKey: ['user-playlist', variables.id, 'games'],
});
showToast('Games added to playlist', 'success');
},
});
}
@@ -179,38 +221,81 @@ export function useRemoveGamesFromUserPlaylist() {
const queryClient = useQueryClient();
const { showToast } = useDialog();
return useMutation({
return useMutation<
PlaylistWithGames,
unknown,
{ id: number; gameIds: string[] },
{ previousPlaylist: unknown; previousGames: unknown; previousPlaylists: unknown }
>({
mutationFn: ({ id, gameIds }: { id: number; gameIds: string[] }) =>
userPlaylistsApi.removeGames(id, gameIds),
onMutate: async ({ id, gameIds }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['user-playlist', id] });
await queryClient.cancelQueries({ queryKey: ['user-playlist', id, 'games'] });
const previousGames = queryClient.getQueryData(['user-playlist', id, 'games']);
await queryClient.cancelQueries({ queryKey: ['user-playlists'] });
// Optimistically remove games
queryClient.setQueryData<Game[]>(['user-playlist', id, 'games'], (old = []) => {
return old.filter((game) => !gameIds.includes(game.id));
// Snapshot previous values for rollback
const previousPlaylist = queryClient.getQueryData(['user-playlist', id]);
const previousGames = queryClient.getQueryData<Game[]>(['user-playlist', id, 'games']);
const previousPlaylists = queryClient.getQueryData(['user-playlists']);
// Optimistically remove games from cache for immediate feedback
if (previousGames) {
queryClient.setQueryData<Game[]>(
['user-playlist', id, 'games'],
previousGames.filter((game) => !gameIds.includes(game.id))
);
}
return { previousPlaylist, previousGames, previousPlaylists };
},
onSuccess: (response, variables) => {
// Update single playlist cache with new gameCount
queryClient.setQueryData(['user-playlist', variables.id], {
id: response.id,
userId: response.userId,
title: response.title,
description: response.description,
icon: response.icon,
createdAt: response.createdAt,
updatedAt: response.updatedAt,
isPublic: response.isPublic,
gameCount: response.gameCount, // Updated count from backend
});
return { previousGames };
// Update games list cache with authoritative backend response
queryClient.setQueryData(['user-playlist', variables.id, 'games'], response.games);
// Update playlists list cache
queryClient.setQueryData<UserPlaylist[]>(['user-playlists'], (old = []) => {
return old.map((playlist) =>
playlist.id === variables.id
? { ...playlist, gameCount: response.gameCount }
: playlist
);
});
showToast('Games removed from playlist', 'success');
},
onError: (err: unknown, variables, context) => {
if (context?.previousGames) {
queryClient.setQueryData(
['user-playlist', variables.id, 'games'],
context.previousGames
);
// Rollback all caches on error
if (context?.previousPlaylist) {
queryClient.setQueryData(['user-playlist', variables.id], context.previousPlaylist);
}
if (context?.previousGames) {
queryClient.setQueryData(['user-playlist', variables.id, 'games'], context.previousGames);
}
if (context?.previousPlaylists) {
queryClient.setQueryData(['user-playlists'], context.previousPlaylists);
}
const message = getErrorMessage(err) || 'Failed to remove games from playlist';
showToast(message, 'error');
},
onSuccess: (_, variables) => {
// Invalidate queries to refetch updated data
queryClient.invalidateQueries({ queryKey: ['user-playlist', variables.id] });
showToast('Games removed from playlist', 'success');
},
});
}
+47 -4
View File
@@ -56,6 +56,13 @@ import { SystemSettings, PublicSettings } from '@/types/settings';
import { JobStatusEnriched, JobLogsResponse } from '@/types/jobs';
import { useAuthStore } from '@/store/auth';
/**
* Playlist with games array (returned from add/remove operations)
*/
export interface PlaylistWithGames extends UserPlaylist {
games: Game[];
}
const api = axios.create({
baseURL: '/api',
headers: {
@@ -543,12 +550,14 @@ export const userPlaylistsApi = {
await api.delete(`/user-playlists/${id}`);
},
addGames: async (id: number, gameIds: string[]): Promise<void> => {
await api.post(`/user-playlists/${id}/games`, { gameIds });
addGames: async (id: number, gameIds: string[]): Promise<PlaylistWithGames> => {
const { data } = await api.post<PlaylistWithGames>(`/user-playlists/${id}/games`, { gameIds });
return data;
},
removeGames: async (id: number, gameIds: string[]): Promise<void> => {
await api.delete(`/user-playlists/${id}/games`, { data: { gameIds } });
removeGames: async (id: number, gameIds: string[]): Promise<PlaylistWithGames> => {
const { data } = await api.delete<PlaylistWithGames>(`/user-playlists/${id}/games`, { data: { gameIds } });
return data;
},
reorderGames: async (id: number, gameIdOrder: string[]): Promise<void> => {
@@ -720,6 +729,19 @@ export const githubApi = {
// Axios Interceptors
// ===================================
// Token refresh state management to prevent race conditions
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
function subscribeTokenRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
}
// Request interceptor - Add JWT token to requests
api.interceptors.request.use(
(config) => {
@@ -749,6 +771,11 @@ api.interceptors.response.use(
const status = error.response.status;
// Note: We do NOT handle 403 errors here. Following HTTP standards:
// - 401 = Authentication failure (invalid/expired token) - handled below
// - 403 = Authorization failure (valid token, insufficient permissions) - let calling code handle
// The backend follows these standards, so we should too.
// Handle 404 errors - show toast notification
if (status === 404) {
const { toast } = await import('sonner');
@@ -772,7 +799,18 @@ api.interceptors.response.use(
return Promise.reject(error);
}
// If a token refresh is already in progress, wait for it
if (isRefreshing) {
return new Promise((resolve) => {
subscribeTokenRefresh((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(api(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = useAuthStore.getState().refreshToken;
@@ -792,6 +830,9 @@ api.interceptors.response.use(
// Update the access token in the store
useAuthStore.getState().updateAccessToken(tokens.accessToken);
// Notify all waiting requests of the new token
onTokenRefreshed(tokens.accessToken);
// Update the authorization header and retry the original request
originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`;
return api(originalRequest);
@@ -802,6 +843,8 @@ api.interceptors.response.use(
useAuthStore.getState().clearAuth();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
+19 -4
View File
@@ -7,6 +7,13 @@ import type {
} from '@/types/playlist';
import type { Game } from '@/types/game';
/**
* Playlist with games array (returned from add/remove operations)
*/
export interface PlaylistWithGames extends UserPlaylist {
games: Game[];
}
/**
* User Playlists API
*
@@ -71,15 +78,23 @@ export const userPlaylistsApi = {
/**
* Add games to a playlist
*/
addGames: async (id: number, gameIds: string[]): Promise<void> => {
await apiClient.post(`/user-playlists/${id}/games`, { gameIds });
addGames: async (id: number, gameIds: string[]): Promise<PlaylistWithGames> => {
const { data } = await apiClient.post<PlaylistWithGames>(
`/user-playlists/${id}/games`,
{ gameIds }
);
return data;
},
/**
* Remove games from a playlist
*/
removeGames: async (id: number, gameIds: string[]): Promise<void> => {
await apiClient.delete(`/user-playlists/${id}/games`, { data: { gameIds } });
removeGames: async (id: number, gameIds: string[]): Promise<PlaylistWithGames> => {
const { data } = await apiClient.delete<PlaylistWithGames>(
`/user-playlists/${id}/games`,
{ data: { gameIds } }
);
return data;
},
/**
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
import { PLAYLIST_ICONS, type PlaylistIconName } from './playlistIcons';
/**
* Validates that there are no duplicate icon components in PLAYLIST_ICONS
* This is a development-only utility to ensure icon uniqueness
*
* Checks:
* 1. No two icon keys point to the same Lucide component
* 2. All icons are valid Lucide components
* 3. Icon count matches expected (600 icons)
*
* @returns Object with validation results
*
* @example
* // In development console
* import { validateIconsNoDuplicates } from '@/lib/validateIcons';
* validateIconsNoDuplicates();
*/
export function validateIconsNoDuplicates(): {
isValid: boolean;
totalIcons: number;
duplicates: Array<{ iconKeys: string[]; componentName: string }>;
warnings: string[];
} {
const componentMap = new Map<string, string[]>();
const warnings: string[] = [];
// Build a map of component names to icon keys that use them
for (const [iconKey, IconComponent] of Object.entries(PLAYLIST_ICONS)) {
// Get the component's display name or name
const componentName = IconComponent.displayName || IconComponent.name || 'Unknown';
if (!componentMap.has(componentName)) {
componentMap.set(componentName, []);
}
componentMap.get(componentName)!.push(iconKey);
}
// Find duplicates (multiple keys using the same component)
const duplicates: Array<{ iconKeys: string[]; componentName: string }> = [];
for (const [componentName, iconKeys] of componentMap.entries()) {
if (iconKeys.length > 1) {
duplicates.push({ componentName, iconKeys });
}
}
// Check icon count
const totalIcons = Object.keys(PLAYLIST_ICONS).length;
const expectedIconCount = 600;
if (totalIcons !== expectedIconCount) {
warnings.push(
`Icon count mismatch: Expected ${expectedIconCount}, got ${totalIcons}`
);
}
// Check for invalid icons (components that are null or undefined)
for (const [iconKey, IconComponent] of Object.entries(PLAYLIST_ICONS)) {
if (!IconComponent) {
warnings.push(`Invalid icon: ${iconKey} is null or undefined`);
}
}
const isValid = duplicates.length === 0 && warnings.length === 0;
return {
isValid,
totalIcons,
duplicates,
warnings,
};
}
/**
* Logs validation results to console with formatting
* Use this during development to check icon integrity
* DEV-only function - only logs in development mode
*
* @example
* import { logIconValidation } from '@/lib/validateIcons';
* logIconValidation();
*/
export function logIconValidation(): void {
if (!import.meta.env.DEV) {
return; // Only log in development mode
}
const result = validateIconsNoDuplicates();
console.group('🔍 Icon Validation Results');
console.log(`Total Icons: ${result.totalIcons}`);
console.log(`Valid: ${result.isValid ? '✅' : '❌'}`);
if (result.warnings.length > 0) {
console.group('⚠️ Warnings');
result.warnings.forEach((warning) => console.warn(warning));
console.groupEnd();
}
if (result.duplicates.length > 0) {
console.group('🔴 Duplicate Icons Found');
result.duplicates.forEach(({ componentName, iconKeys }) => {
console.log(`\n${componentName}:`);
iconKeys.forEach((key) => console.log(` - ${key}`));
});
console.groupEnd();
} else {
console.log('✅ No duplicates found');
}
console.groupEnd();
}
/**
* Checks if an icon name is valid
* @param iconName - Icon name to check
* @returns True if icon exists in PLAYLIST_ICONS
*/
export function isValidIconName(iconName: string): iconName is PlaylistIconName {
return iconName in PLAYLIST_ICONS;
}
/**
* Development-only: Validate icons on module load (DEV only)
* This will log warnings to console if duplicates are detected
*/
if (import.meta.env.DEV) {
const result = validateIconsNoDuplicates();
if (!result.isValid) {
console.warn(
`⚠️ Icon validation failed: ${result.duplicates.length} duplicates, ${result.warnings.length} warnings`
);
console.warn('Run logIconValidation() for details');
} else {
console.log(`✅ Icon validation passed: ${result.totalIcons} unique icons`);
}
}
+5 -3
View File
@@ -1,3 +1,5 @@
import type { PlaylistIconName } from '@/lib/playlistIcons';
/**
* User Playlist Types
*/
@@ -7,7 +9,7 @@ export interface UserPlaylist {
userId: number;
title: string;
description: string | null;
icon: string | null;
icon: PlaylistIconName | null;
createdAt: string;
updatedAt: string;
isPublic: boolean;
@@ -26,13 +28,13 @@ export interface PlaylistGame {
export interface CreatePlaylistData {
title: string;
description?: string;
icon?: string;
icon?: PlaylistIconName | null;
}
export interface UpdatePlaylistData {
title?: string;
description?: string;
icon?: string;
icon?: PlaylistIconName | null;
}
export interface PlaylistStats {
+45
View File
@@ -0,0 +1,45 @@
export type ActionCategory = 'auth' | 'crud' | 'error' | 'system';
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
/**
* Categorize an action string into one of four categories.
* This logic is shared between frontend (badge styling) and backend (analytics).
*/
export function categorizeAction(action: string): ActionCategory {
if (action.includes('fail') || action.includes('error')) {
return 'error';
}
if (
action.startsWith('auth') ||
action === 'login' ||
action === 'logout' ||
action === 'register'
) {
return 'auth';
}
if (
action.includes('schedule') ||
action.includes('cleanup') ||
action.includes('maintenance')
) {
return 'system';
}
return 'crud';
}
/**
* Get the appropriate badge variant for an action based on its category.
*/
export function getActionBadgeVariant(action: string): BadgeVariant {
const category = categorizeAction(action);
switch (category) {
case 'error':
return 'destructive';
case 'auth':
return 'default';
case 'system':
return 'outline';
case 'crud':
return 'secondary';
}
}
+30 -12
View File
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ViewOptions } from '@/components/common/ViewOptions';
import { CreateUserPlaylistDialog } from '@/components/playlist/CreateUserPlaylistDialog';
import { PlaylistIcon } from '@/components/playlist/PlaylistIcon';
import { useUIStore } from '@/store/ui';
import { useAuthStore } from '@/store/auth';
import { toast } from 'sonner';
@@ -111,19 +112,36 @@ export function UserPlaylistDetailView() {
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold">{playlist.title}</h1>
{playlist.isPublic && (
<Badge variant="secondary">Public</Badge>
)}
</div>
{playlist.description && (
<p className="text-muted-foreground">{playlist.description}</p>
<div className="flex gap-4">
{/* Playlist Icon */}
{playlist.icon && (
<div className="flex-shrink-0">
<div className="p-4 bg-primary/20 rounded-xl border-2 border-primary/30">
<PlaylistIcon
iconName={playlist.icon}
size={48}
className="text-primary"
aria-label={`${playlist.title} icon`}
/>
</div>
</div>
)}
<p className="text-sm text-muted-foreground mt-2">
{playlist.gameCount} {playlist.gameCount === 1 ? 'game' : 'games'}
</p>
{/* Playlist Info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold">{playlist.title}</h1>
{playlist.isPublic && (
<Badge variant="secondary">Public</Badge>
)}
</div>
{playlist.description && (
<p className="text-muted-foreground">{playlist.description}</p>
)}
<p className="text-sm text-muted-foreground mt-2">
{playlist.gameCount} {playlist.gameCount === 1 ? 'game' : 'games'}
</p>
</div>
</div>
{/* Actions */}
+28
View File
@@ -2816,6 +2816,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.28.9",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
@@ -8578,6 +8579,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@@ -8591,6 +8609,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",