feat: review and polish playlists feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ?
|
||||
`);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Generated
+28
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user