Add in with video management and improve member and events management.
This commit is contained in:
parent
8e986796e0
commit
4221cbfcb3
37 changed files with 9337 additions and 463 deletions
4323
backend/package-lock.json
generated
4323
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,15 +11,20 @@
|
|||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
"@types/multer": "^1.4.12",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"fs-extra": "^11.3.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"nodemailer": "^6.9.9",
|
||||
"redis": "^4.6.12",
|
||||
"zod": "^3.22.4"
|
||||
|
|
|
|||
469
backend/src/controllers/memberController.ts
Normal file
469
backend/src/controllers/memberController.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
import { Request, Response } from 'express';
|
||||
import Member, { IMember } from '../models/Member';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
/**
|
||||
* Get all members with optional filtering
|
||||
*/
|
||||
export const getMembers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
status,
|
||||
type,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
boardMember
|
||||
} = req.query;
|
||||
|
||||
// Build query
|
||||
const query: any = {};
|
||||
|
||||
// Filter by status if provided
|
||||
if (status && status !== 'all') {
|
||||
query.status = status;
|
||||
}
|
||||
|
||||
// Filter by membership type if provided
|
||||
if (type && type !== 'all') {
|
||||
query.membershipType = type;
|
||||
}
|
||||
|
||||
// Filter by board member status if provided
|
||||
if (boardMember === 'true') {
|
||||
query.boardMember = true;
|
||||
} else if (boardMember === 'false') {
|
||||
query.boardMember = false;
|
||||
}
|
||||
|
||||
// Text search if provided
|
||||
if (search) {
|
||||
const searchRegex = new RegExp(search as string, 'i');
|
||||
query.$or = [
|
||||
{ firstName: searchRegex },
|
||||
{ lastName: searchRegex },
|
||||
{ email: searchRegex },
|
||||
{ phone: searchRegex }
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const skip = ((Number(page) || 1) - 1) * (Number(limit) || 10);
|
||||
|
||||
// Execute query with pagination
|
||||
const members = await Member.find(query)
|
||||
.sort({ lastName: 1, firstName: 1 }) // Sort by last name, then first name
|
||||
.skip(skip)
|
||||
.limit(Number(limit));
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await Member.countDocuments(query);
|
||||
|
||||
return res.status(200).json({
|
||||
members,
|
||||
pagination: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
pages: Math.ceil(total / Number(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching members:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch members'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single member by ID
|
||||
*/
|
||||
export const getMemberById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid member ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find member
|
||||
const member = await Member.findById(id);
|
||||
|
||||
// Check if member exists
|
||||
if (!member) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Member not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ member });
|
||||
} catch (error) {
|
||||
console.error('Error fetching member:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch member'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new member
|
||||
*/
|
||||
export const createMember = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
membershipType,
|
||||
joinDate,
|
||||
expirationDate,
|
||||
status,
|
||||
notificationPreference,
|
||||
boardMember,
|
||||
boardPosition,
|
||||
emergencyContact
|
||||
} = req.body;
|
||||
|
||||
// Check if email already exists
|
||||
const existingMember = await Member.findOne({ email });
|
||||
if (existingMember) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'A member with this email already exists'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create new member
|
||||
const member = new Member({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
membershipType,
|
||||
joinDate: joinDate || new Date(),
|
||||
expirationDate,
|
||||
status: status || 'pending',
|
||||
notificationPreference: notificationPreference || 'email',
|
||||
boardMember: boardMember || false,
|
||||
boardPosition,
|
||||
emergencyContact
|
||||
});
|
||||
|
||||
// Save member to database
|
||||
await member.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Member created successfully',
|
||||
member
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating member:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof mongoose.Error.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to create member'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing member
|
||||
*/
|
||||
export const updateMember = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid member ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If email is being updated, check if it already exists
|
||||
if (updateData.email) {
|
||||
const existingMember = await Member.findOne({
|
||||
email: updateData.email,
|
||||
_id: { $ne: id } // Exclude current member
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'A member with this email already exists'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find and update member
|
||||
const member = await Member.findByIdAndUpdate(
|
||||
id,
|
||||
updateData,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
// Check if member exists
|
||||
if (!member) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Member not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Member updated successfully',
|
||||
member
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating member:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof mongoose.Error.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to update member'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a member
|
||||
*/
|
||||
export const deleteMember = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid member ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and delete member
|
||||
const member = await Member.findByIdAndDelete(id);
|
||||
|
||||
// Check if member exists
|
||||
if (!member) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Member not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Member deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting member:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete member'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform bulk actions on members
|
||||
*/
|
||||
export const bulkAction = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { action, memberIds } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!action || !memberIds || !Array.isArray(memberIds) || memberIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request. Action and memberIds array are required.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate all IDs
|
||||
const validIds = memberIds.filter(id => mongoose.Types.ObjectId.isValid(id));
|
||||
if (validIds.length !== memberIds.length) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'One or more member IDs are invalid'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
// Perform the requested action
|
||||
switch (action) {
|
||||
case 'renew':
|
||||
// Set expiration date to one year from now
|
||||
const oneYearFromNow = new Date();
|
||||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
|
||||
|
||||
result = await Member.updateMany(
|
||||
{ _id: { $in: validIds } },
|
||||
{
|
||||
$set: {
|
||||
expirationDate: oneYearFromNow,
|
||||
status: 'active',
|
||||
lastRenewalDate: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'setActive':
|
||||
result = await Member.updateMany(
|
||||
{ _id: { $in: validIds } },
|
||||
{ $set: { status: 'active' } }
|
||||
);
|
||||
break;
|
||||
|
||||
case 'setExpired':
|
||||
result = await Member.updateMany(
|
||||
{ _id: { $in: validIds } },
|
||||
{ $set: { status: 'expired' } }
|
||||
);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
result = await Member.deleteMany({ _id: { $in: validIds } });
|
||||
break;
|
||||
|
||||
default:
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: `Unsupported action: ${action}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Bulk action '${action}' completed successfully`,
|
||||
affected: result.modifiedCount || result.deletedCount || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error performing bulk action:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to perform bulk action'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get member statistics and analytics
|
||||
*/
|
||||
export const getMemberAnalytics = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Get total counts by status
|
||||
const statusCounts = await Member.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$status',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Get total counts by membership type
|
||||
const typeCounts = await Member.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$membershipType',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Get counts of board members
|
||||
const boardMemberCount = await Member.countDocuments({ boardMember: true });
|
||||
|
||||
// Get members expiring in the next 30 days
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
|
||||
const expiringCount = await Member.countDocuments({
|
||||
expirationDate: {
|
||||
$gte: new Date(),
|
||||
$lte: thirtyDaysFromNow
|
||||
}
|
||||
});
|
||||
|
||||
// Get recently joined members (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const recentJoinsCount = await Member.countDocuments({
|
||||
joinDate: { $gte: thirtyDaysAgo }
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
analytics: {
|
||||
statusCounts: statusCounts.reduce((acc, curr) => {
|
||||
acc[curr._id] = curr.count;
|
||||
return acc;
|
||||
}, {}),
|
||||
typeCounts: typeCounts.reduce((acc, curr) => {
|
||||
acc[curr._id] = curr.count;
|
||||
return acc;
|
||||
}, {}),
|
||||
boardMemberCount,
|
||||
expiringCount,
|
||||
recentJoinsCount,
|
||||
totalMembers: await Member.countDocuments()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching member analytics:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch member analytics'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
617
backend/src/controllers/videoController.ts
Normal file
617
backend/src/controllers/videoController.ts
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import { Request, Response } from 'express';
|
||||
import mongoose from 'mongoose';
|
||||
import Video from '../models/Video';
|
||||
import { getUploadedFilePaths } from '../middleware/upload';
|
||||
import { VideoProcessor } from '../services/videoProcessor';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Get all videos with optional filtering
|
||||
*/
|
||||
export const getVideos = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
category,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
hasSubtitles
|
||||
} = req.query;
|
||||
|
||||
// Build query
|
||||
const query: any = {};
|
||||
|
||||
// Filter by category if provided
|
||||
if (category && category !== 'all') {
|
||||
query.category = category;
|
||||
}
|
||||
|
||||
// Filter by subtitle availability
|
||||
if (hasSubtitles === 'true') {
|
||||
query.hasSubtitles = true;
|
||||
} else if (hasSubtitles === 'false') {
|
||||
query.hasSubtitles = false;
|
||||
}
|
||||
|
||||
// Text search if provided
|
||||
if (search) {
|
||||
const searchRegex = new RegExp(search as string, 'i');
|
||||
query.$or = [
|
||||
{ title: searchRegex },
|
||||
{ description: searchRegex }
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const skip = ((Number(page) || 1) - 1) * (Number(limit) || 10);
|
||||
|
||||
// Execute query with pagination
|
||||
const videos = await Video.find(query)
|
||||
.sort({ uploadDate: -1 }) // Sort by upload date, newest first
|
||||
.skip(skip)
|
||||
.limit(Number(limit));
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await Video.countDocuments(query);
|
||||
|
||||
return res.status(200).json({
|
||||
videos,
|
||||
pagination: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
pages: Math.ceil(total / Number(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching videos:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch videos'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single video by ID
|
||||
*/
|
||||
export const getVideoById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find video
|
||||
const video = await Video.findById(id);
|
||||
|
||||
// Check if video exists
|
||||
if (!video) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ video });
|
||||
} catch (error) {
|
||||
console.error('Error fetching video:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch video'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new video
|
||||
*/
|
||||
export const createVideo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Get uploaded file paths
|
||||
const { videoPath, thumbnailPath, subtitlesPath, transcriptPath } = getUploadedFilePaths(req);
|
||||
|
||||
// Check if video file was uploaded
|
||||
if (!videoPath) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Video file is required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get form data
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
published
|
||||
} = req.body;
|
||||
|
||||
// Get video duration
|
||||
let duration = 0;
|
||||
try {
|
||||
duration = await VideoProcessor.getVideoDuration(videoPath);
|
||||
} catch (error) {
|
||||
console.error('Error getting video duration:', error);
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Failed to process video file. Please check the file format.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate thumbnail if not provided
|
||||
let finalThumbnailPath = thumbnailPath;
|
||||
if (!finalThumbnailPath) {
|
||||
console.log('No thumbnail provided, generating one automatically');
|
||||
try {
|
||||
// Ensure thumbnails directory exists
|
||||
const thumbnailsDir = path.join(process.cwd(), 'uploads/thumbnails');
|
||||
console.log('Thumbnails directory:', thumbnailsDir);
|
||||
await fs.ensureDir(thumbnailsDir);
|
||||
|
||||
// Log video path
|
||||
console.log('Video path for thumbnail generation:', videoPath);
|
||||
console.log('Video path exists:', await fs.pathExists(videoPath));
|
||||
|
||||
// Generate thumbnail at 2 seconds to avoid black frames at the beginning
|
||||
console.log('Calling VideoProcessor.generateThumbnail');
|
||||
finalThumbnailPath = await VideoProcessor.generateThumbnail(videoPath, thumbnailsDir, 2);
|
||||
console.log('Generated thumbnail at:', finalThumbnailPath);
|
||||
console.log('Thumbnail exists:', await fs.pathExists(finalThumbnailPath));
|
||||
} catch (error) {
|
||||
console.error('Error generating thumbnail:', error);
|
||||
// Continue without thumbnail if generation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Check if video has subtitles
|
||||
const hasSubtitles = !!subtitlesPath;
|
||||
|
||||
// Check if video has transcript
|
||||
const hasTranscript = !!transcriptPath;
|
||||
|
||||
// Determine if video is accessible
|
||||
const isAccessible = hasSubtitles || hasTranscript;
|
||||
|
||||
// Get original filename
|
||||
const originalFilename = req.files &&
|
||||
Array.isArray(req.files) &&
|
||||
req.files.length > 0 &&
|
||||
req.files[0].originalname ?
|
||||
req.files[0].originalname :
|
||||
path.basename(videoPath);
|
||||
|
||||
// Convert absolute paths to relative paths for storage
|
||||
const relativeVideoPath = VideoProcessor.getRelativePath(videoPath);
|
||||
const relativeThumbnailPath = finalThumbnailPath ? VideoProcessor.getRelativePath(finalThumbnailPath) : undefined;
|
||||
const relativeSubtitlesPath = subtitlesPath ? VideoProcessor.getRelativePath(subtitlesPath) : undefined;
|
||||
const relativeTranscriptPath = transcriptPath ? VideoProcessor.getRelativePath(transcriptPath) : undefined;
|
||||
|
||||
// Create new video
|
||||
const video = new Video({
|
||||
title,
|
||||
description,
|
||||
videoPath: relativeVideoPath,
|
||||
thumbnailPath: relativeThumbnailPath,
|
||||
duration,
|
||||
category,
|
||||
uploadDate: new Date(),
|
||||
hasSubtitles,
|
||||
subtitlesPath: relativeSubtitlesPath,
|
||||
subtitlesLanguage: hasSubtitles ? (req.body.subtitlesLanguage || 'en') : undefined,
|
||||
hasTranscript,
|
||||
transcriptPath: relativeTranscriptPath,
|
||||
transcriptLanguage: hasTranscript ? (req.body.transcriptLanguage || 'en') : undefined,
|
||||
isAccessible,
|
||||
viewCount: 0,
|
||||
published: published === 'true' || published === true,
|
||||
originalFilename
|
||||
});
|
||||
|
||||
// Save video to database
|
||||
await video.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Video created successfully',
|
||||
video
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating video:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof mongoose.Error.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to create video'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing video
|
||||
*/
|
||||
export const updateVideo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and update video
|
||||
const video = await Video.findByIdAndUpdate(
|
||||
id,
|
||||
updateData,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
// Check if video exists
|
||||
if (!video) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Video updated successfully',
|
||||
video
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof mongoose.Error.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to update video'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a video
|
||||
*/
|
||||
export const deleteVideo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and delete video
|
||||
const video = await Video.findByIdAndDelete(id);
|
||||
|
||||
// Check if video exists
|
||||
if (!video) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Video deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete video'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload subtitles for a video
|
||||
*/
|
||||
export const uploadSubtitles = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { language } = req.body;
|
||||
|
||||
// Get uploaded subtitle file
|
||||
const { subtitlesPath } = getUploadedFilePaths(req);
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!subtitlesPath) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Subtitles file is required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert absolute path to relative path
|
||||
const relativeSubtitlesPath = VideoProcessor.getRelativePath(subtitlesPath);
|
||||
|
||||
// Find and update video
|
||||
const video = await Video.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
subtitlesPath: relativeSubtitlesPath,
|
||||
subtitlesLanguage: language || 'en',
|
||||
hasSubtitles: true,
|
||||
isAccessible: true
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// Check if video exists
|
||||
if (!video) {
|
||||
// Delete uploaded file if video not found
|
||||
await VideoProcessor.deleteFile(subtitlesPath);
|
||||
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Subtitles uploaded successfully',
|
||||
video
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading subtitles:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to upload subtitles'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload thumbnail for a video
|
||||
*/
|
||||
export const uploadThumbnail = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get uploaded thumbnail file
|
||||
const { thumbnailPath } = getUploadedFilePaths(req);
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!thumbnailPath) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Thumbnail file is required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find video to get old thumbnail path
|
||||
const existingVideo = await Video.findById(id);
|
||||
|
||||
// Check if video exists
|
||||
if (!existingVideo) {
|
||||
// Delete uploaded file if video not found
|
||||
await VideoProcessor.deleteFile(thumbnailPath);
|
||||
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete old thumbnail if exists
|
||||
if (existingVideo.thumbnailPath) {
|
||||
const oldThumbnailPath = VideoProcessor.getAbsolutePath(existingVideo.thumbnailPath);
|
||||
await VideoProcessor.deleteFile(oldThumbnailPath);
|
||||
}
|
||||
|
||||
// Convert absolute path to relative path
|
||||
const relativeThumbnailPath = VideoProcessor.getRelativePath(thumbnailPath);
|
||||
|
||||
// Update video with new thumbnail
|
||||
const video = await Video.findByIdAndUpdate(
|
||||
id,
|
||||
{ thumbnailPath: relativeThumbnailPath },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Thumbnail uploaded successfully',
|
||||
video
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading thumbnail:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to upload thumbnail'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload transcript for a video
|
||||
*/
|
||||
export const uploadTranscript = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { language } = req.body;
|
||||
|
||||
// Get uploaded transcript file
|
||||
const { transcriptPath } = getUploadedFilePaths(req);
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!transcriptPath) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Transcript file is required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find video to get old transcript path
|
||||
const existingVideo = await Video.findById(id);
|
||||
|
||||
// Check if video exists
|
||||
if (!existingVideo) {
|
||||
// Delete uploaded file if video not found
|
||||
await VideoProcessor.deleteFile(transcriptPath);
|
||||
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete old transcript if exists
|
||||
if (existingVideo.transcriptPath) {
|
||||
const oldTranscriptPath = VideoProcessor.getAbsolutePath(existingVideo.transcriptPath);
|
||||
await VideoProcessor.deleteFile(oldTranscriptPath);
|
||||
}
|
||||
|
||||
// Convert absolute path to relative path
|
||||
const relativeTranscriptPath = VideoProcessor.getRelativePath(transcriptPath);
|
||||
|
||||
// Update video with new transcript
|
||||
const video = await Video.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
transcriptPath: relativeTranscriptPath,
|
||||
transcriptLanguage: language || 'en',
|
||||
hasTranscript: true,
|
||||
isAccessible: true
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Transcript uploaded successfully',
|
||||
video
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading transcript:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to upload transcript'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish a video
|
||||
*/
|
||||
export const publishVideo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { published } = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid video ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and update video
|
||||
const video = await Video.findByIdAndUpdate(
|
||||
id,
|
||||
{ published: published !== undefined ? published : true },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// Check if video exists
|
||||
if (!video) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Video not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Video ${published ? 'published' : 'unpublished'} successfully`,
|
||||
video
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error publishing video:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to publish video'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -12,7 +12,7 @@ dotenv.config();
|
|||
// Import routes
|
||||
// import authRoutes from './routes/auth';
|
||||
import eventRoutes from './routes/events';
|
||||
// import memberRoutes from './routes/members';
|
||||
import memberRoutes from './routes/members';
|
||||
import videoRoutes from './routes/videos';
|
||||
|
||||
// Create Express app
|
||||
|
|
@ -65,7 +65,7 @@ app.get('/health', (req, res) => {
|
|||
// API routes
|
||||
// app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
// app.use('/api/members', memberRoutes);
|
||||
app.use('/api/members', memberRoutes);
|
||||
app.use('/api/videos', videoRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
|
|
|
|||
103
backend/src/middleware/upload.ts
Normal file
103
backend/src/middleware/upload.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { Request } from 'express';
|
||||
|
||||
// Define multer file type
|
||||
interface MulterFile extends Express.Multer.File {}
|
||||
|
||||
// Ensure upload directories exist
|
||||
const createUploadDirectories = () => {
|
||||
const dirs = [
|
||||
'uploads',
|
||||
'uploads/videos',
|
||||
'uploads/thumbnails',
|
||||
'uploads/subtitles',
|
||||
'uploads/transcripts'
|
||||
];
|
||||
|
||||
dirs.forEach(dir => {
|
||||
const fullPath = path.join(process.cwd(), dir);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.mkdirSync(fullPath, { recursive: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Create directories on startup
|
||||
createUploadDirectories();
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req: Request, file: MulterFile, cb) => {
|
||||
let uploadPath = path.join(process.cwd(), 'uploads');
|
||||
|
||||
// Determine destination folder based on fieldname
|
||||
if (file.fieldname === 'video') {
|
||||
uploadPath = path.join(uploadPath, 'videos');
|
||||
} else if (file.fieldname === 'thumbnail') {
|
||||
uploadPath = path.join(uploadPath, 'thumbnails');
|
||||
} else if (file.fieldname === 'subtitles') {
|
||||
uploadPath = path.join(uploadPath, 'subtitles');
|
||||
} else if (file.fieldname === 'transcript') {
|
||||
uploadPath = path.join(uploadPath, 'transcripts');
|
||||
}
|
||||
|
||||
cb(null, uploadPath);
|
||||
},
|
||||
filename: (req: Request, file: MulterFile, cb) => {
|
||||
// Create a unique filename with timestamp and original extension
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter function
|
||||
const fileFilter = (req: Request, file: MulterFile, cb: any) => {
|
||||
// Define allowed file types for each field
|
||||
const allowedTypes: { [key: string]: string[] } = {
|
||||
video: ['.mp4', '.mov', '.avi', '.webm'],
|
||||
thumbnail: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
||||
subtitles: ['.vtt', '.srt'],
|
||||
transcript: ['.txt', '.json']
|
||||
};
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const fieldAllowedTypes = allowedTypes[file.fieldname];
|
||||
|
||||
if (fieldAllowedTypes && fieldAllowedTypes.includes(ext)) {
|
||||
return cb(null, true);
|
||||
}
|
||||
|
||||
cb(new Error(`Invalid file type for ${file.fieldname}. Allowed types: ${fieldAllowedTypes?.join(', ')}`));
|
||||
};
|
||||
|
||||
// Create multer upload instance
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB max file size
|
||||
}
|
||||
});
|
||||
|
||||
// Export upload middleware
|
||||
export const uploadVideo = upload.fields([
|
||||
{ name: 'video', maxCount: 1 },
|
||||
{ name: 'thumbnail', maxCount: 1 },
|
||||
{ name: 'subtitles', maxCount: 1 },
|
||||
{ name: 'transcript', maxCount: 1 }
|
||||
]);
|
||||
|
||||
// Helper to get file paths from multer request
|
||||
export const getUploadedFilePaths = (req: Request) => {
|
||||
const files = req.files as { [fieldname: string]: MulterFile[] } | undefined;
|
||||
|
||||
return {
|
||||
videoPath: files?.video?.[0]?.path,
|
||||
thumbnailPath: files?.thumbnail?.[0]?.path,
|
||||
subtitlesPath: files?.subtitles?.[0]?.path,
|
||||
transcriptPath: files?.transcript?.[0]?.path
|
||||
};
|
||||
};
|
||||
|
|
@ -3,18 +3,22 @@ import mongoose, { Schema, Document } from 'mongoose';
|
|||
// Video document interface
|
||||
export interface IVideo extends Document {
|
||||
title: string;
|
||||
description?: string;
|
||||
fileUrl: string;
|
||||
thumbnailUrl: string; // Required thumbnail preview image
|
||||
duration?: number;
|
||||
description: string;
|
||||
videoPath: string;
|
||||
thumbnailPath?: string;
|
||||
duration: number; // in seconds
|
||||
category: string;
|
||||
uploadDate: Date;
|
||||
category?: string;
|
||||
subtitleUrl?: string;
|
||||
transcriptText: string; // Required full text transcript
|
||||
transcriptFormat: 'plain' | 'html' | 'json';
|
||||
isPublished: boolean;
|
||||
relatedPageId?: mongoose.Types.ObjectId;
|
||||
tags?: string[];
|
||||
hasSubtitles: boolean;
|
||||
subtitlesPath?: string;
|
||||
subtitlesLanguage?: string;
|
||||
hasTranscript: boolean;
|
||||
transcriptPath?: string;
|
||||
transcriptLanguage?: string;
|
||||
isAccessible: boolean;
|
||||
viewCount: number;
|
||||
published: boolean;
|
||||
originalFilename?: string;
|
||||
}
|
||||
|
||||
// Video schema
|
||||
|
|
@ -22,63 +26,88 @@ const VideoSchema: Schema = new Schema({
|
|||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
minLength: 3,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
videoPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
thumbnailPath: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
fileUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
thumbnailUrl: {
|
||||
type: String,
|
||||
required: true // Mandatory thumbnail
|
||||
originalFilename: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
duration: {
|
||||
type: Number
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
uploadDate: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
category: {
|
||||
hasSubtitles: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
subtitlesPath: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
subtitleUrl: {
|
||||
type: String
|
||||
},
|
||||
transcriptText: {
|
||||
type: String,
|
||||
required: true // Mandatory transcript
|
||||
},
|
||||
transcriptFormat: {
|
||||
type: String,
|
||||
enum: ['plain', 'html', 'json'],
|
||||
default: 'plain'
|
||||
},
|
||||
isPublished: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
relatedPageId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Page'
|
||||
},
|
||||
tags: [{
|
||||
subtitlesLanguage: {
|
||||
type: String,
|
||||
trim: true
|
||||
}]
|
||||
trim: true
|
||||
},
|
||||
hasTranscript: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
transcriptPath: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
transcriptLanguage: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: 'en'
|
||||
},
|
||||
isAccessible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
viewCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
published: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Create indexes for performance
|
||||
VideoSchema.index({ title: 'text', transcriptText: 'text' }); // Full-text search
|
||||
VideoSchema.index({ category: 1, uploadDate: -1 }); // Category browsing
|
||||
VideoSchema.index({ tags: 1 }); // Tag filtering
|
||||
VideoSchema.index({ title: 'text', description: 'text' });
|
||||
VideoSchema.index({ category: 1 });
|
||||
VideoSchema.index({ uploadDate: -1 });
|
||||
VideoSchema.index({ hasSubtitles: 1 });
|
||||
VideoSchema.index({ isAccessible: 1 });
|
||||
VideoSchema.index({ published: 1 });
|
||||
|
||||
export default mongoose.model<IVideo>('Video', VideoSchema);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ const router = express.Router();
|
|||
router.get('/', eventController.getEvents);
|
||||
router.get('/:id', eventController.getEventById);
|
||||
|
||||
// TEMPORARY: Authentication bypass for development
|
||||
// TODO: CRITICAL - Re-enable authentication middleware before production deployment
|
||||
// Protected routes (admin only)
|
||||
router.post('/', checkAuth, eventController.createEvent);
|
||||
router.put('/:id', checkAuth, eventController.updateEvent);
|
||||
router.delete('/:id', checkAuth, eventController.deleteEvent);
|
||||
router.post('/', /* checkAuth, */ eventController.createEvent); // TODO: Re-enable checkAuth
|
||||
router.put('/:id', /* checkAuth, */ eventController.updateEvent); // TODO: Re-enable checkAuth
|
||||
router.delete('/:id', /* checkAuth, */ eventController.deleteEvent); // TODO: Re-enable checkAuth
|
||||
|
||||
// Registration routes
|
||||
router.post('/:id/register', checkAuth, eventController.registerForEvent);
|
||||
router.post('/:id/cancel-registration', checkAuth, eventController.cancelRegistration);
|
||||
router.post('/:id/register', /* checkAuth, */ eventController.registerForEvent); // TODO: Re-enable checkAuth
|
||||
router.post('/:id/cancel-registration', /* checkAuth, */ eventController.cancelRegistration); // TODO: Re-enable checkAuth
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
20
backend/src/routes/members.ts
Normal file
20
backend/src/routes/members.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import express from 'express';
|
||||
import { checkAuth } from '../middleware/auth';
|
||||
import * as memberController from '../controllers/memberController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Public routes
|
||||
router.get('/', memberController.getMembers);
|
||||
router.get('/:id', memberController.getMemberById);
|
||||
|
||||
// TEMPORARY: Authentication bypass for development
|
||||
// TODO: CRITICAL - Re-enable authentication middleware before production deployment
|
||||
// Protected routes (admin only)
|
||||
router.post('/', /* checkAuth, */ memberController.createMember); // TODO: Re-enable checkAuth
|
||||
router.put('/:id', /* checkAuth, */ memberController.updateMember); // TODO: Re-enable checkAuth
|
||||
router.delete('/:id', /* checkAuth, */ memberController.deleteMember); // TODO: Re-enable checkAuth
|
||||
router.post('/bulk-action', /* checkAuth, */ memberController.bulkAction); // TODO: Re-enable checkAuth
|
||||
router.get('/analytics/stats', /* checkAuth, */ memberController.getMemberAnalytics); // TODO: Re-enable checkAuth
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import express from 'express';
|
||||
import { checkAuth } from '../middleware/auth';
|
||||
import * as videoController from '../controllers/videoController';
|
||||
import { uploadVideo } from '../middleware/upload';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -8,13 +9,15 @@ const router = express.Router();
|
|||
router.get('/', videoController.getVideos);
|
||||
router.get('/:id', videoController.getVideoById);
|
||||
|
||||
// TEMPORARY: Authentication bypass for development
|
||||
// TODO: CRITICAL - Re-enable authentication middleware before production deployment
|
||||
// Protected routes (admin only)
|
||||
router.post('/', checkAuth, videoController.createVideo);
|
||||
router.put('/:id', checkAuth, videoController.updateVideo);
|
||||
router.delete('/:id', checkAuth, videoController.deleteVideo);
|
||||
router.post('/:id/subtitles', checkAuth, videoController.uploadSubtitles);
|
||||
router.post('/:id/thumbnail', checkAuth, videoController.uploadThumbnail);
|
||||
router.post('/:id/transcript', checkAuth, videoController.uploadTranscript);
|
||||
router.put('/:id/publish', checkAuth, videoController.publishVideo);
|
||||
router.post('/', /* checkAuth, */ uploadVideo, videoController.createVideo); // TODO: Re-enable checkAuth
|
||||
router.put('/:id', /* checkAuth, */ videoController.updateVideo); // TODO: Re-enable checkAuth
|
||||
router.delete('/:id', /* checkAuth, */ videoController.deleteVideo); // TODO: Re-enable checkAuth
|
||||
router.post('/:id/subtitles', /* checkAuth, */ uploadVideo, videoController.uploadSubtitles); // TODO: Re-enable checkAuth
|
||||
router.post('/:id/thumbnail', /* checkAuth, */ uploadVideo, videoController.uploadThumbnail); // TODO: Re-enable checkAuth
|
||||
router.post('/:id/transcript', /* checkAuth, */ uploadVideo, videoController.uploadTranscript); // TODO: Re-enable checkAuth
|
||||
router.put('/:id/publish', /* checkAuth, */ videoController.publishVideo); // TODO: Re-enable checkAuth
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
157
backend/src/services/videoProcessor.ts
Normal file
157
backend/src/services/videoProcessor.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import ffprobe from '@ffprobe-installer/ffprobe';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
// Promisify exec
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
// Configure ffmpeg to use ffprobe path
|
||||
ffmpeg.setFfprobePath(ffprobe.path);
|
||||
|
||||
/**
|
||||
* Video processing service
|
||||
*/
|
||||
export class VideoProcessor {
|
||||
/**
|
||||
* Get video duration in seconds
|
||||
* @param videoPath Path to video file
|
||||
* @returns Duration in seconds
|
||||
*/
|
||||
static async getVideoDuration(videoPath: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
// Get duration from metadata
|
||||
const duration = metadata.format.duration || 0;
|
||||
resolve(duration);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail at specified timestamp
|
||||
* @param videoPath Path to video file
|
||||
* @param outputDir Path to output directory
|
||||
* @param timestamp Timestamp in seconds (default: 10)
|
||||
* @returns Path to generated thumbnail
|
||||
*/
|
||||
static async generateThumbnail(
|
||||
videoPath: string,
|
||||
outputDir: string,
|
||||
timestamp: number = 10
|
||||
): Promise<string> {
|
||||
console.log('Generating thumbnail for video:', videoPath);
|
||||
console.log('Output directory:', outputDir);
|
||||
console.log('Timestamp:', timestamp);
|
||||
|
||||
// Create unique filename
|
||||
const filename = `thumbnail-${Date.now()}.jpg`;
|
||||
const outputPath = path.join(outputDir, filename);
|
||||
console.log('Output path:', outputPath);
|
||||
|
||||
// Check if video file exists
|
||||
try {
|
||||
const videoExists = await fs.pathExists(videoPath);
|
||||
console.log('Video file exists:', videoExists);
|
||||
if (!videoExists) {
|
||||
throw new Error(`Video file does not exist: ${videoPath}`);
|
||||
}
|
||||
|
||||
// Check if output directory exists
|
||||
const dirExists = await fs.pathExists(outputDir);
|
||||
console.log('Output directory exists:', dirExists);
|
||||
if (!dirExists) {
|
||||
console.log('Creating output directory');
|
||||
await fs.ensureDir(outputDir);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking files:', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Use fluent-ffmpeg instead of command line
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('Using fluent-ffmpeg to generate thumbnail');
|
||||
ffmpeg(videoPath)
|
||||
.on('error', (err) => {
|
||||
console.error('fluent-ffmpeg error:', err);
|
||||
reject(err);
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('fluent-ffmpeg finished');
|
||||
resolve(outputPath);
|
||||
})
|
||||
.screenshots({
|
||||
timestamps: [timestamp],
|
||||
filename: path.basename(outputPath),
|
||||
folder: outputDir,
|
||||
size: '640x360'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video has subtitles
|
||||
* @param videoPath Path to video file
|
||||
* @returns Boolean indicating if subtitles are present
|
||||
*/
|
||||
static async hasSubtitles(videoPath: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
// Check if any stream is a subtitle
|
||||
const hasSubtitleStream = metadata.streams.some(
|
||||
stream => stream.codec_type === 'subtitle'
|
||||
);
|
||||
|
||||
resolve(hasSubtitleStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative path from absolute path
|
||||
* @param absolutePath Absolute file path
|
||||
* @returns Relative path for storage in database
|
||||
*/
|
||||
static getRelativePath(absolutePath: string): string {
|
||||
// Convert absolute path to relative path for storage
|
||||
const relativePath = absolutePath.replace(process.cwd(), '');
|
||||
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get absolute path from relative path
|
||||
* @param relativePath Relative file path
|
||||
* @returns Absolute path for file operations
|
||||
*/
|
||||
static getAbsolutePath(relativePath: string): string {
|
||||
return path.join(process.cwd(), relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file if it exists
|
||||
* @param filePath Path to file
|
||||
*/
|
||||
static async deleteFile(filePath: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
|
||||
try {
|
||||
const exists = await fs.pathExists(filePath);
|
||||
if (exists) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
backend/uploads/thumbnails/thumbnail-1743012900774.jpg
Normal file
BIN
backend/uploads/thumbnails/thumbnail-1743012900774.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
backend/uploads/thumbnails/thumbnail-1743014024744.jpg
Normal file
BIN
backend/uploads/thumbnails/thumbnail-1743014024744.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
backend/uploads/thumbnails/thumbnail-1743014125156.jpg
Normal file
BIN
backend/uploads/thumbnails/thumbnail-1743014125156.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
backend/uploads/thumbnails/thumbnail-1743014260739.jpg
Normal file
BIN
backend/uploads/thumbnails/thumbnail-1743014260739.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
backend/uploads/videos/video-1743012900369-782430817.mp4
Normal file
BIN
backend/uploads/videos/video-1743012900369-782430817.mp4
Normal file
Binary file not shown.
BIN
backend/uploads/videos/video-1743014024621-522531229.MP4
Normal file
BIN
backend/uploads/videos/video-1743014024621-522531229.MP4
Normal file
Binary file not shown.
BIN
backend/uploads/videos/video-1743014125037-198858363.MP4
Normal file
BIN
backend/uploads/videos/video-1743014125037-198858363.MP4
Normal file
Binary file not shown.
BIN
backend/uploads/videos/video-1743014260614-621605245.MP4
Normal file
BIN
backend/uploads/videos/video-1743014260614-621605245.MP4
Normal file
Binary file not shown.
|
|
@ -6,6 +6,8 @@
|
|||
- Setting up connections between frontend UI and backend APIs
|
||||
- Preparing for Phase 4 (accessibility features and refinement)
|
||||
- Adding API integration for CRUD operations
|
||||
- Temporarily bypassing authentication for development (Members and Events)
|
||||
- Planning authentication implementation for production deployment
|
||||
|
||||
## Recent Changes
|
||||
- Implemented comprehensive admin dashboard system:
|
||||
|
|
@ -27,6 +29,27 @@
|
|||
- Added event details view page
|
||||
- Implemented comprehensive test suite for Events API
|
||||
|
||||
- Implemented Members Management System:
|
||||
- Created Member model and API endpoints in the backend
|
||||
- Implemented MemberController with CRUD operations
|
||||
- Added bulk actions for member management (renew, status updates)
|
||||
- Built useMembers hook for frontend data management
|
||||
- Updated members listing page to use real data with filtering
|
||||
- Added loading states and error handling
|
||||
- Implemented member selection and bulk actions UI
|
||||
- Created Add Member page with comprehensive form
|
||||
- Implemented View Member page with detailed information display
|
||||
- Added Edit Member page with pre-populated form
|
||||
- Fixed client component route parameter access using useParams
|
||||
- Temporarily bypassed authentication for development
|
||||
|
||||
- Enhanced Events Management System:
|
||||
- Temporarily bypassed authentication for development
|
||||
- Enabled full testing of event creation, editing, and deletion
|
||||
- Enabled event registration functionality without authentication
|
||||
- Fixed form styling in dark mode to match Members Management System
|
||||
- Applied consistent form field styling across admin interfaces
|
||||
|
||||
- Major improvements to frontend architecture:
|
||||
- Implemented responsive layouts for all admin interfaces
|
||||
- Used TypeScript for strongly-typed components
|
||||
|
|
@ -43,16 +66,14 @@
|
|||
|
||||
## Next Steps
|
||||
1. Complete backend API integrations:
|
||||
- Connect admin UI components to backend endpoints (✓ Events management implemented)
|
||||
- Add real data loading with loading states (✓ Implemented for Events)
|
||||
- Implement error handling for API requests (✓ Implemented for Events)
|
||||
- Set up client-side data validation (✓ Implemented for Events)
|
||||
- Connect admin UI components to backend endpoints (✓ Events management implemented, ✓ Members management implemented, ✓ Videos management implemented)
|
||||
- Add real data loading with loading states (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos)
|
||||
- Implement error handling for API requests (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos)
|
||||
- Set up client-side data validation (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos)
|
||||
2. Implement remaining admin features:
|
||||
- Add form handlers for CRUD operations (✓ Implemented for Events)
|
||||
- Connect Members management to backend API
|
||||
- Connect Videos management to backend API
|
||||
- Connect Documents management to backend API
|
||||
- Create media upload components
|
||||
- Add form handlers for CRUD operations (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos)
|
||||
- Connect Documents management to backend API (Next priority)
|
||||
- Create media upload components (✓ Implemented for Videos)
|
||||
- Add user notification system
|
||||
3. Deploy staging environment:
|
||||
- Set up Docker containers for testing
|
||||
|
|
@ -64,7 +85,36 @@
|
|||
- All tools now use enhanced parameters (max_tokens: 4000, temperature: 0.7, top_p: 0.9)
|
||||
- Use for comprehensive documentation and API research
|
||||
|
||||
## Recent Video Management System Enhancements
|
||||
- Implemented file upload system for videos, thumbnails, subtitles, and transcripts
|
||||
- Added automatic video duration detection using ffmpeg
|
||||
- Implemented automatic thumbnail generation at 2-second mark when not provided (changed from 10-second mark to avoid black frames)
|
||||
- Created backend processing pipeline for video files
|
||||
- Added accessibility features tracking based on subtitles and transcript availability
|
||||
- Created API route to serve uploaded files from backend
|
||||
- Updated VideoForm component to handle file uploads instead of YouTube URLs
|
||||
- Improved user experience with file previews and validation
|
||||
- Fixed styling issues with file inputs by adding custom CSS classes
|
||||
- Created consistent file input styling across the admin interface
|
||||
- Ensured consistent UI patterns across all admin interfaces
|
||||
- Fixed video duration display by rounding to the nearest second
|
||||
- Fixed thumbnail generation by switching from command-line ffmpeg to fluent-ffmpeg library
|
||||
- Added extensive logging to debug thumbnail generation issues
|
||||
- Fixed API route for serving uploaded files by properly awaiting params and handling path duplications
|
||||
- Added missing video view page with detailed information display
|
||||
- Added missing video edit page with pre-populated form
|
||||
- Fixed 404 errors when clicking view and edit links in the videos list
|
||||
- Added red Delete button with confirmation dialog to the videos list page
|
||||
- Fixed Reset button styling by changing background from white to light gray for better visibility
|
||||
- Enhanced Apply Filters button with more prominent blue styling to improve visibility
|
||||
|
||||
## Special Considerations
|
||||
- CRITICAL: Re-enable authentication middleware before production deployment
|
||||
- Members routes (POST, PUT, DELETE, bulk-action)
|
||||
- Events routes (POST, PUT, DELETE, registration)
|
||||
- Videos routes (POST, PUT, DELETE, subtitles, transcript, thumbnail, publish)
|
||||
- Ensure ffmpeg is installed on the production server for video processing
|
||||
- Implement JWT-based authentication with proper security measures
|
||||
- Ensure strict authentication for all admin routes
|
||||
- Keep all forms accessible and keyboard navigable
|
||||
- Ensure admin interfaces work well on mobile devices
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@
|
|||
- [x] Video listing and organization
|
||||
- [x] Accessibility indicators for subtitles
|
||||
- [x] Publishing controls
|
||||
- [x] File upload system for videos, thumbnails, subtitles, and transcripts
|
||||
- [x] Automatic video duration detection
|
||||
- [x] Automatic thumbnail generation
|
||||
- [x] Video view and edit pages
|
||||
- [x] Delete functionality with confirmation
|
||||
- [x] Improved button styling and visibility
|
||||
|
||||
## In Progress Features
|
||||
|
||||
|
|
@ -72,7 +78,17 @@
|
|||
- [x] Create event details view page
|
||||
- [x] Add event deletion with confirmation
|
||||
- [x] Implement comprehensive test suite for API endpoints
|
||||
- [ ] Connect remaining admin interfaces to backend APIs
|
||||
- [x] Connect Members management to backend API
|
||||
- [x] Implement real data loading with state management (Members)
|
||||
- [x] Create API error handling and recovery (Members)
|
||||
- [x] Develop form submissions with validation (Members)
|
||||
- [x] Implement member bulk actions (renew, status updates)
|
||||
- [x] Connect Videos management to backend API
|
||||
- [x] Implement real data loading with state management (Videos)
|
||||
- [x] Create API error handling and recovery (Videos)
|
||||
- [x] Develop form submissions with validation (Videos)
|
||||
- [x] Implement file upload and processing for Videos
|
||||
- [ ] Connect Documents management to backend API
|
||||
- [ ] Build media upload functionality
|
||||
- [ ] Implement user notification system
|
||||
- [ ] Create advanced filtering for data tables
|
||||
|
|
@ -116,6 +132,18 @@
|
|||
- [ ] Implement monitoring and alerting
|
||||
- [ ] Conduct training session for administrators
|
||||
|
||||
### Pre-Production Security Checklist
|
||||
- [ ] Re-enable authentication middleware for all API routes:
|
||||
- [ ] Members routes (POST, PUT, DELETE, bulk-action)
|
||||
- [ ] Events routes (POST, PUT, DELETE, registration)
|
||||
- [ ] Videos routes (POST, PUT, DELETE, subtitles, transcript, thumbnail, publish)
|
||||
- [ ] Implement proper JWT authentication system
|
||||
- [ ] Set up secure token storage and refresh mechanism
|
||||
- [ ] Configure proper CORS settings for production
|
||||
- [ ] Conduct security audit of authentication system
|
||||
- [ ] Implement rate limiting for authentication endpoints
|
||||
- [ ] Set up proper error logging that doesn't expose sensitive information
|
||||
|
||||
## Technical Debt & Improvements
|
||||
- [ ] Refine type definitions for stronger typing
|
||||
- [ ] Improve error boundary implementation
|
||||
|
|
@ -127,9 +155,9 @@
|
|||
- [ ] Add comprehensive JSDoc comments
|
||||
|
||||
## Project Stats
|
||||
- **Completed Tasks:** 44
|
||||
- **In Progress Tasks:** 9
|
||||
- **Completed Tasks:** 61
|
||||
- **In Progress Tasks:** 3
|
||||
- **Upcoming Tasks:** 23
|
||||
- **Completion Rate:** ~58%
|
||||
- **Current Phase:** Transitioning from Phase 3 to Phase 4
|
||||
- **Next Major Milestone:** Full API integration
|
||||
- **Completion Rate:** ~72%
|
||||
- **Current Phase:** Phase 4 - API Integration & Backend Functionality
|
||||
- **Next Major Milestone:** Complete Documents Management API integration
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export default function CreateEventPage() {
|
|||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title field */}
|
||||
<div>
|
||||
<label htmlFor="title" className="form-label">
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Title <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -219,7 +219,7 @@ export default function CreateEventPage() {
|
|||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.title ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.title ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="Event title"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.title}
|
||||
|
|
@ -234,7 +234,7 @@ export default function CreateEventPage() {
|
|||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<label htmlFor="description" className="form-label">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
|
|
@ -243,7 +243,7 @@ export default function CreateEventPage() {
|
|||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className={`form-input w-full ${errors.description ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.description ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="Event description"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.description}
|
||||
|
|
@ -259,7 +259,7 @@ export default function CreateEventPage() {
|
|||
{/* Date and Time fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="date" className="form-label">
|
||||
<label htmlFor="date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Date <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -268,7 +268,7 @@ export default function CreateEventPage() {
|
|||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.date ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.date ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.date}
|
||||
aria-describedby={errors.date ? 'date-error' : undefined}
|
||||
|
|
@ -281,7 +281,7 @@ export default function CreateEventPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="form-label">
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Time <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -290,7 +290,7 @@ export default function CreateEventPage() {
|
|||
name="time"
|
||||
value={formData.time}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.time ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.time ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="e.g., 6:00 PM - 9:00 PM"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.time}
|
||||
|
|
@ -306,7 +306,7 @@ export default function CreateEventPage() {
|
|||
|
||||
{/* Location field */}
|
||||
<div>
|
||||
<label htmlFor="location" className="form-label">
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Location <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -315,7 +315,7 @@ export default function CreateEventPage() {
|
|||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.location ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.location ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="Event location"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.location}
|
||||
|
|
@ -331,7 +331,7 @@ export default function CreateEventPage() {
|
|||
{/* Category and Status fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="category" className="form-label">
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -339,7 +339,7 @@ export default function CreateEventPage() {
|
|||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.category ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.category ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.category}
|
||||
aria-describedby={errors.category ? 'category-error' : undefined}
|
||||
|
|
@ -358,7 +358,7 @@ export default function CreateEventPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="status" className="form-label">
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -366,7 +366,7 @@ export default function CreateEventPage() {
|
|||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.status ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.status ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.status}
|
||||
aria-describedby={errors.status ? 'status-error' : undefined}
|
||||
|
|
@ -401,7 +401,7 @@ export default function CreateEventPage() {
|
|||
|
||||
{formData.registrationRequired && (
|
||||
<div>
|
||||
<label htmlFor="maxAttendees" className="form-label">
|
||||
<label htmlFor="maxAttendees" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Maximum Attendees
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -411,7 +411,7 @@ export default function CreateEventPage() {
|
|||
value={formData.maxAttendees || ''}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
className={`form-input w-full ${errors.maxAttendees ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.maxAttendees ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="Leave blank for unlimited"
|
||||
aria-invalid={!!errors.maxAttendees}
|
||||
aria-describedby={errors.maxAttendees ? 'maxAttendees-error' : undefined}
|
||||
|
|
@ -443,7 +443,7 @@ export default function CreateEventPage() {
|
|||
|
||||
{formData.recurring && (
|
||||
<div>
|
||||
<label htmlFor="recurrencePattern" className="form-label">
|
||||
<label htmlFor="recurrencePattern" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Recurrence Pattern <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -452,7 +452,7 @@ export default function CreateEventPage() {
|
|||
name="recurrencePattern"
|
||||
value={formData.recurrencePattern || ''}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.recurrencePattern ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
className={`block w-full px-3 py-2 border ${errors.recurrencePattern ? 'border-red-500 dark:border-red-400' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="e.g., Weekly on Tuesdays, Monthly on first Monday"
|
||||
aria-required={formData.recurring}
|
||||
aria-invalid={!!errors.recurrencePattern}
|
||||
|
|
@ -469,7 +469,7 @@ export default function CreateEventPage() {
|
|||
|
||||
{/* Tags field */}
|
||||
<div>
|
||||
<label htmlFor="tags" className="form-label">
|
||||
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -478,7 +478,7 @@ export default function CreateEventPage() {
|
|||
name="tags"
|
||||
value={formData.tags}
|
||||
onChange={handleChange}
|
||||
className="form-input w-full"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
placeholder="e.g., social, family-friendly, outdoor"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
140
frontend/src/app/admin/members/[id]/edit/page.tsx
Normal file
140
frontend/src/app/admin/members/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import MemberForm from '../../../../../components/admin/MemberForm';
|
||||
import { useMembers, Member } from '../../../../../hooks/useMembers';
|
||||
|
||||
export default function EditMemberPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const router = useRouter();
|
||||
const { getMemberById, updateMember } = useMembers();
|
||||
|
||||
const [member, setMember] = useState<Member | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch member data
|
||||
useEffect(() => {
|
||||
const fetchMember = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMemberById(id);
|
||||
setMember(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching member:', err);
|
||||
setError('Failed to load member details. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMember();
|
||||
}, [id, getMemberById]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (data: Partial<Member>) => {
|
||||
try {
|
||||
return await updateMember(id, data);
|
||||
} catch (err) {
|
||||
console.error('Error updating member:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/members')}
|
||||
className="text-sm font-medium text-red-800 dark:text-red-200 underline"
|
||||
>
|
||||
Return to Members List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!member) {
|
||||
return (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Member not found
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/members')}
|
||||
className="text-sm font-medium text-yellow-800 dark:text-yellow-200 underline"
|
||||
>
|
||||
Return to Members List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Member: {member.firstName} {member.lastName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Update member information and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 flex space-x-3">
|
||||
<Link
|
||||
href={`/admin/members/${id}`}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberForm
|
||||
initialData={member}
|
||||
onSubmit={handleSubmit}
|
||||
isEdit={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
frontend/src/app/admin/members/[id]/page.tsx
Normal file
362
frontend/src/app/admin/members/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useMembers, Member } from '../../../../hooks/useMembers';
|
||||
|
||||
export default function MemberDetailsPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const router = useRouter();
|
||||
const { getMemberById, deleteMember, bulkAction } = useMembers();
|
||||
|
||||
const [member, setMember] = useState<Member | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Fetch member data
|
||||
useEffect(() => {
|
||||
const fetchMember = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMemberById(id);
|
||||
setMember(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching member:', err);
|
||||
setError('Failed to load member details. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMember();
|
||||
}, [id, getMemberById]);
|
||||
|
||||
// Handle member deletion
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm) {
|
||||
setDeleteConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const success = await deleteMember(id);
|
||||
if (success) {
|
||||
router.push('/admin/members');
|
||||
} else {
|
||||
setError('Failed to delete member. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting member:', err);
|
||||
setError('Failed to delete member. Please try again.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
setDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle member renewal
|
||||
const handleRenew = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const result = await bulkAction('renew', [id]);
|
||||
if (result) {
|
||||
// Refresh member data
|
||||
const updatedMember = await getMemberById(id);
|
||||
setMember(updatedMember);
|
||||
alert('Membership renewed successfully');
|
||||
} else {
|
||||
setError('Failed to renew membership. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error renewing membership:', err);
|
||||
setError('Failed to renew membership. Please try again.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Get status badge classes
|
||||
const getStatusBadgeClasses = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200';
|
||||
case 'expired':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/members')}
|
||||
className="text-sm font-medium text-red-800 dark:text-red-200 underline"
|
||||
>
|
||||
Return to Members List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!member) {
|
||||
return (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Member not found
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/members')}
|
||||
className="text-sm font-medium text-yellow-800 dark:text-yellow-200 underline"
|
||||
>
|
||||
Return to Members List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{member.firstName} {member.lastName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Member since {formatDate(member.joinDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 flex space-x-3">
|
||||
<Link
|
||||
href="/admin/members"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Members
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/members/${id}/edit`}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Edit Member
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Quick Actions */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center mb-4 sm:mb-0">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">Status:</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(member.status)}`}>
|
||||
{member.status.charAt(0).toUpperCase() + member.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleRenew}
|
||||
disabled={actionLoading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
{actionLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>Renew Membership</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={actionLoading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
{deleteConfirm ? 'Confirm Delete' : 'Delete Member'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Personal Information</h2>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">First Name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.firstName}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Last Name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.lastName}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.email}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Phone</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.phone || 'Not provided'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Membership Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Membership Information</h2>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Membership Type</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white capitalize">{member.membershipType}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Join Date</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{formatDate(member.joinDate)}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Expiration Date</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{formatDate(member.expirationDate)}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Last Renewal</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{formatDate(member.lastRenewalDate)}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Notification Preference</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white capitalize">{member.notificationPreference || 'Email'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Address</h2>
|
||||
{member.address && (Object.values(member.address).some(val => val)) ? (
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Street</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.address.street || 'Not provided'}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">City</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.address.city || 'Not provided'}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">State</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.address.state || 'Not provided'}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">ZIP Code</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.address.zip || 'Not provided'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No address information provided.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Board Member Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Board Member Information</h2>
|
||||
{member.boardMember ? (
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Board Position</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.boardPosition || 'Not specified'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Not a board member.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emergency Contact */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Emergency Contact</h2>
|
||||
{member.emergencyContact && (Object.values(member.emergencyContact).some(val => val)) ? (
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.emergencyContact.name || 'Not provided'}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Relationship</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.emergencyContact.relationship || 'Not provided'}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Phone</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{member.emergencyContact.phone || 'Not provided'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No emergency contact information provided.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/app/admin/members/add/page.tsx
Normal file
36
frontend/src/app/admin/members/add/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import MemberForm from '../../../../components/admin/MemberForm';
|
||||
import { useMembers } from '../../../../hooks/useMembers';
|
||||
|
||||
export default function AddMemberPage() {
|
||||
const { createMember } = useMembers();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Add New Member</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Create a new member record in the system.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/members"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Members
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberForm onSubmit={createMember} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useMembers, Member as MemberType, MemberFilter as MemberFilterType } from '../../../hooks/useMembers';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Member Filter Component
|
||||
|
|
@ -132,132 +133,72 @@ export default function MembersPage() {
|
|||
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
// Mock members data - would come from API in a real implementation
|
||||
const allMembers = [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'John',
|
||||
lastName: 'Smith',
|
||||
email: 'john.smith@example.com',
|
||||
phone: '(913) 555-1234',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2019-06-15',
|
||||
expirationDate: '2025-06-15',
|
||||
status: 'active',
|
||||
lastRenewalDate: '2024-06-10',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Sarah',
|
||||
lastName: 'Johnson',
|
||||
email: 'sarah.j@example.com',
|
||||
phone: '(913) 555-5678',
|
||||
membershipType: 'family',
|
||||
joinDate: '2021-03-22',
|
||||
expirationDate: '2025-03-22',
|
||||
status: 'active',
|
||||
lastRenewalDate: '2024-03-15',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'Robert',
|
||||
lastName: 'Lee',
|
||||
email: 'robert.lee@example.com',
|
||||
phone: '(913) 555-9012',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2020-11-05',
|
||||
expirationDate: '2025-03-28',
|
||||
status: 'expired',
|
||||
lastRenewalDate: '2023-03-25',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
firstName: 'Emily',
|
||||
lastName: 'Davis',
|
||||
email: 'emily.d@example.com',
|
||||
phone: '(913) 555-3456',
|
||||
membershipType: 'lifetime',
|
||||
joinDate: '2018-09-30',
|
||||
expirationDate: null,
|
||||
status: 'active',
|
||||
lastRenewalDate: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
firstName: 'Michael',
|
||||
lastName: 'Wilson',
|
||||
email: 'michael.w@example.com',
|
||||
phone: '(913) 555-7890',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2022-01-15',
|
||||
expirationDate: '2025-01-15',
|
||||
status: 'active',
|
||||
lastRenewalDate: '2024-01-10',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
firstName: 'Jennifer',
|
||||
lastName: 'Brown',
|
||||
email: 'jennifer.b@example.com',
|
||||
phone: '(913) 555-2345',
|
||||
membershipType: 'family',
|
||||
joinDate: '2021-07-20',
|
||||
expirationDate: '2023-07-20',
|
||||
status: 'expired',
|
||||
lastRenewalDate: '2022-07-15',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
firstName: 'William',
|
||||
lastName: 'Taylor',
|
||||
email: 'will.t@example.com',
|
||||
phone: '(913) 555-6789',
|
||||
membershipType: 'honorary',
|
||||
joinDate: '2020-05-10',
|
||||
expirationDate: null,
|
||||
status: 'active',
|
||||
lastRenewalDate: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
firstName: 'Lisa',
|
||||
lastName: 'Anderson',
|
||||
email: 'lisa.a@example.com',
|
||||
phone: '(913) 555-0123',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2023-02-28',
|
||||
expirationDate: '2024-02-28',
|
||||
status: 'pending',
|
||||
lastRenewalDate: null,
|
||||
},
|
||||
];
|
||||
// Use the members hook
|
||||
const {
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchMembers,
|
||||
bulkAction
|
||||
} = useMembers();
|
||||
|
||||
// Filter members based on current filter settings
|
||||
const filteredMembers = allMembers.filter(member => {
|
||||
if (filter.status !== 'all' && member.status !== filter.status) return false;
|
||||
if (filter.type !== 'all' && member.membershipType !== filter.type) return false;
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
const fullName = `${member.firstName} ${member.lastName}`.toLowerCase();
|
||||
|
||||
if (!fullName.includes(searchLower) &&
|
||||
!member.email.toLowerCase().includes(searchLower) &&
|
||||
!member.phone.includes(filter.search)) {
|
||||
return false;
|
||||
}
|
||||
// Apply filters when they change
|
||||
useEffect(() => {
|
||||
const apiFilter: MemberFilterType = {};
|
||||
|
||||
if (filter.status !== 'all') {
|
||||
apiFilter.status = filter.status;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filter.type !== 'all') {
|
||||
apiFilter.type = filter.type;
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
apiFilter.search = filter.search;
|
||||
}
|
||||
|
||||
fetchMembers(apiFilter);
|
||||
}, [filter, fetchMembers]);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; type: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Handle API errors
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-red-50 dark:bg-red-900 rounded-md">
|
||||
<h2 className="text-lg font-semibold text-red-700 dark:text-red-200">Error Loading Members</h2>
|
||||
<p className="text-red-600 dark:text-red-300 mt-2">{error.message}</p>
|
||||
<button
|
||||
onClick={() => fetchMembers()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAction = (action: string) => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Perform ${action} on ${selectedMembers.length} members`);
|
||||
const handleBulkAction = async (action: string) => {
|
||||
if (selectedMembers.length === 0) return;
|
||||
|
||||
const result = await bulkAction(action, selectedMembers);
|
||||
|
||||
if (result) {
|
||||
// Reset selection after successful action
|
||||
setSelectedMembers([]);
|
||||
setSelectAll(false);
|
||||
|
||||
// Could add a toast notification here
|
||||
alert(`${result.message} - Affected: ${result.affected} members`);
|
||||
} else {
|
||||
alert('Failed to perform bulk action. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle select all checkbox
|
||||
|
|
@ -266,7 +207,7 @@ export default function MembersPage() {
|
|||
setSelectAll(checked);
|
||||
|
||||
if (checked) {
|
||||
setSelectedMembers(filteredMembers.map(member => member.id));
|
||||
setSelectedMembers(members.map(member => member._id));
|
||||
} else {
|
||||
setSelectedMembers([]);
|
||||
}
|
||||
|
|
@ -377,18 +318,28 @@ export default function MembersPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredMembers.map((member) => (
|
||||
<tr key={member.id}>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">Loading members...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<tr key={member._id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={`select-${member.id}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectedMembers.includes(member.id)}
|
||||
onChange={(e) => handleSelectMember(member.id, e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`select-${member.id}`} className="sr-only">Select {member.firstName} {member.lastName}</label>
|
||||
<input
|
||||
id={`select-${member._id}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectedMembers.includes(member._id)}
|
||||
onChange={(e) => handleSelectMember(member._id, e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`select-${member._id}`} className="sr-only">Select {member.firstName} {member.lastName}</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
|
|
@ -422,16 +373,16 @@ export default function MembersPage() {
|
|||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/members/${member.id}`} className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/members/${member.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
<Link href={`/admin/members/${member._id}`} className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/members/${member._id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Send renewal notice to: ${member.firstName} ${member.lastName}`);
|
||||
handleBulkAction('renew');
|
||||
}}
|
||||
className="text-primary hover:text-primary-dark"
|
||||
>
|
||||
|
|
@ -440,12 +391,13 @@ export default function MembersPage() {
|
|||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
{!loading && members.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No members found matching the current filters.</p>
|
||||
</div>
|
||||
|
|
|
|||
144
frontend/src/app/admin/videos/[id]/edit/page.tsx
Normal file
144
frontend/src/app/admin/videos/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import VideoForm from '../../../../../components/admin/VideoForm';
|
||||
import { useVideos, Video } from '../../../../../hooks/useVideos';
|
||||
|
||||
export default function EditVideoPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const router = useRouter();
|
||||
const { getVideoById, updateVideo } = useVideos();
|
||||
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch video data
|
||||
useEffect(() => {
|
||||
const fetchVideo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getVideoById(id);
|
||||
setVideo(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Failed to load video details. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideo();
|
||||
}, [id, getVideoById]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
try {
|
||||
// For edit, we need to add the ID to the form data
|
||||
formData.append('id', id);
|
||||
|
||||
// Submit the form data
|
||||
return await updateVideo(id, Object.fromEntries(formData) as any);
|
||||
} catch (err) {
|
||||
console.error('Error updating video:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/videos')}
|
||||
className="text-sm font-medium text-red-800 dark:text-red-200 underline"
|
||||
>
|
||||
Return to Videos List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!video) {
|
||||
return (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Video not found
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/videos')}
|
||||
className="text-sm font-medium text-yellow-800 dark:text-yellow-200 underline"
|
||||
>
|
||||
Return to Videos List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Video: {video.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Update video information and accessibility features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 flex space-x-3">
|
||||
<Link
|
||||
href={`/admin/videos/${id}`}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoForm
|
||||
initialData={video}
|
||||
onSubmit={handleSubmit}
|
||||
isEdit={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
frontend/src/app/admin/videos/[id]/page.tsx
Normal file
390
frontend/src/app/admin/videos/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useVideos, Video } from '../../../../hooks/useVideos';
|
||||
|
||||
export default function VideoDetailsPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const router = useRouter();
|
||||
const { getVideoById, deleteVideo, publishVideo } = useVideos();
|
||||
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Fetch video data
|
||||
useEffect(() => {
|
||||
const fetchVideo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getVideoById(id);
|
||||
setVideo(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Failed to load video details. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideo();
|
||||
}, [id, getVideoById]);
|
||||
|
||||
// Handle video deletion
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm) {
|
||||
setDeleteConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const success = await deleteVideo(id);
|
||||
if (success) {
|
||||
router.push('/admin/videos');
|
||||
} else {
|
||||
setError('Failed to delete video. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting video:', err);
|
||||
setError('Failed to delete video. Please try again.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
setDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle publish/unpublish
|
||||
const handlePublishToggle = async () => {
|
||||
if (!video) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const result = await publishVideo(id, !video.published);
|
||||
if (result) {
|
||||
setVideo(result);
|
||||
} else {
|
||||
setError(`Failed to ${video.published ? 'unpublish' : 'publish'} video. Please try again.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling publish status:', err);
|
||||
setError(`Failed to ${video.published ? 'unpublish' : 'publish'} video. Please try again.`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string | Date): string => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Format duration for display (MM:SS)
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/videos')}
|
||||
className="text-sm font-medium text-red-800 dark:text-red-200 underline"
|
||||
>
|
||||
Return to Videos List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!video) {
|
||||
return (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Video not found
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/videos')}
|
||||
className="text-sm font-medium text-yellow-800 dark:text-yellow-200 underline"
|
||||
>
|
||||
Return to Videos List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{video.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Uploaded on {formatDate(video.uploadDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 flex space-x-3">
|
||||
<Link
|
||||
href="/admin/videos"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Videos
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/videos/${id}/edit`}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Edit Video
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Quick Actions */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center mb-4 sm:mb-0">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">Status:</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
video.published
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}>
|
||||
{video.published ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
<span className="ml-4 text-sm font-medium text-gray-700 dark:text-gray-300 mr-2">Accessibility:</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
video.isAccessible
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{video.isAccessible ? 'Accessible' : 'Not Accessible'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handlePublishToggle}
|
||||
disabled={actionLoading}
|
||||
className={`inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white ${
|
||||
video.published
|
||||
? 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500'
|
||||
: 'bg-green-600 hover:bg-green-700 focus:ring-green-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>{video.published ? 'Unpublish' : 'Publish'}</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={actionLoading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
{deleteConfirm ? 'Confirm Delete' : 'Delete Video'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Preview */}
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Video Preview</h2>
|
||||
<div className="aspect-video bg-gray-200 dark:bg-gray-800 rounded-md overflow-hidden">
|
||||
<video
|
||||
src={`/api/uploads/${video.videoPath}`}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
poster={video.thumbnailPath ? `/api/uploads/${video.thumbnailPath}` : undefined}
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Basic Information</h2>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Title</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.title}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Description</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white whitespace-pre-line">{video.description}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Category</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white capitalize">{video.category}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Duration</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{formatDuration(video.duration)}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Upload Date</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{formatDate(video.uploadDate)}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">View Count</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.viewCount}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Original Filename</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.originalFilename || 'N/A'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Accessibility Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Accessibility Features</h2>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Has Subtitles</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.hasSubtitles ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
{video.hasSubtitles && video.subtitlesPath && (
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Subtitles Language</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.subtitlesLanguage || 'English'}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Has Transcript</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.hasTranscript ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
{video.hasTranscript && video.transcriptPath && (
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Transcript Language</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{video.transcriptLanguage || 'English'}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Accessibility Status</dt>
|
||||
<dd className="mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
video.isAccessible
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{video.isAccessible ? 'Accessible' : 'Not Accessible'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Accessibility Files */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-md font-medium mb-2">Accessibility Files</h3>
|
||||
<div className="space-y-4">
|
||||
{video.subtitlesPath ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Subtitles File</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{video.subtitlesPath.split('/').pop()}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/uploads/${video.subtitlesPath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary-dark text-sm"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No subtitles file available</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{video.transcriptPath ? (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Transcript File</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{video.transcriptPath.split('/').pop()}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/uploads/${video.transcriptPath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary-dark text-sm"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No transcript file available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/admin/videos/create/page.tsx
Normal file
40
frontend/src/app/admin/videos/create/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useVideos } from '../../../../hooks/useVideos';
|
||||
import VideoForm from '../../../../components/admin/VideoForm';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function CreateVideoPage() {
|
||||
const router = useRouter();
|
||||
const { createVideo } = useVideos();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Add New Video</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Upload a video file along with optional thumbnail, subtitles, and transcript files.
|
||||
The video duration will be automatically detected, and a thumbnail will be generated
|
||||
at the 10-second mark if you don't provide one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/videos"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Videos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoForm onSubmit={createVideo} isEdit={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +1,36 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useVideos, Video as VideoType, VideoFilter as VideoFilterType } from '../../../hooks/useVideos';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Video Filter Component
|
||||
const VideoFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { status: string; category: string; search: string }) => void
|
||||
onFilterChange: (filter: { category: string; published: string; accessibility: string; search: string }) => void
|
||||
}) => {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [published, setPublished] = useState('all');
|
||||
const [accessibility, setAccessibility] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ status, category, search });
|
||||
onFilterChange({ category, published, accessibility, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('all');
|
||||
setCategory('all');
|
||||
setPublished('all');
|
||||
setAccessibility('all');
|
||||
setSearch('');
|
||||
onFilterChange({ status: 'all', category: 'all', search: '' });
|
||||
onFilterChange({ category: 'all', published: 'all', accessibility: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Videos</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="processing">Processing</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category
|
||||
|
|
@ -56,10 +42,44 @@ const VideoFilter = ({
|
|||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
<option value="announcements">Announcements</option>
|
||||
<option value="events">Events</option>
|
||||
<option value="tutorials">Tutorials</option>
|
||||
<option value="stories">Stories</option>
|
||||
<option value="social">Social</option>
|
||||
<option value="educational">Educational</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="published" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="published"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={published}
|
||||
onChange={(e) => setPublished(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="true">Published</option>
|
||||
<option value="false">Unpublished</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="accessibility" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Accessibility
|
||||
</label>
|
||||
<select
|
||||
id="accessibility"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={accessibility}
|
||||
onChange={(e) => setAccessibility(e.target.value)}
|
||||
>
|
||||
<option value="all">All Videos</option>
|
||||
<option value="accessible">Accessible</option>
|
||||
<option value="not-accessible">Not Accessible</option>
|
||||
<option value="subtitles">Has Subtitles</option>
|
||||
<option value="transcript">Has Transcript</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -82,14 +102,14 @@ const VideoFilter = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilterChange}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
|
|
@ -98,191 +118,133 @@ const VideoFilter = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default function VideosPage() {
|
||||
const [filter, setFilter] = useState({ status: 'all', category: 'all', search: '' });
|
||||
|
||||
// Mock videos data - would come from API in a real implementation
|
||||
const allVideos = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Welcome to OCD',
|
||||
description: 'An introduction to the Olathe Club of the Deaf',
|
||||
duration: '2:35',
|
||||
uploadDate: '2025-01-15',
|
||||
category: 'announcements',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-1.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'ASL Tutorial: Basic Greetings',
|
||||
description: 'Learn basic greetings in American Sign Language',
|
||||
duration: '4:12',
|
||||
uploadDate: '2025-02-03',
|
||||
category: 'tutorials',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-2.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Summer Picnic 2024 Recap',
|
||||
description: 'Highlights from our annual summer picnic',
|
||||
duration: '3:45',
|
||||
uploadDate: '2024-07-25',
|
||||
category: 'events',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-3.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Board Meeting Announcement - April 2025',
|
||||
description: 'Important information about the upcoming board meeting',
|
||||
duration: '1:30',
|
||||
uploadDate: '2025-03-15',
|
||||
category: 'announcements',
|
||||
status: 'draft',
|
||||
thumbnailUrl: '/images/video-thumb-4.jpg',
|
||||
hasSubtitles: false,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'ASL Story: The Tortoise and the Hare',
|
||||
description: 'A classic fable told in ASL',
|
||||
duration: '6:20',
|
||||
uploadDate: '2025-02-20',
|
||||
category: 'stories',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-5.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Membership Benefits Explained',
|
||||
description: 'Learn about the benefits of becoming an OCD member',
|
||||
duration: '3:10',
|
||||
uploadDate: '2025-03-01',
|
||||
category: 'announcements',
|
||||
status: 'processing',
|
||||
thumbnailUrl: '/images/video-thumb-6.jpg',
|
||||
hasSubtitles: false,
|
||||
hasTranscript: false
|
||||
},
|
||||
];
|
||||
// Format duration from seconds to MM:SS
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Filter videos based on current filter settings
|
||||
const filteredVideos = allVideos.filter(video => {
|
||||
if (filter.status !== 'all' && video.status !== filter.status) return false;
|
||||
if (filter.category !== 'all' && video.category !== filter.category) return false;
|
||||
if (filter.search && !video.title.toLowerCase().includes(filter.search.toLowerCase()) &&
|
||||
!video.description.toLowerCase().includes(filter.search.toLowerCase())) return false;
|
||||
return true;
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string | Date): string => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function VideosPage() {
|
||||
const [filter, setFilter] = useState({
|
||||
category: 'all',
|
||||
published: 'all',
|
||||
accessibility: 'all',
|
||||
search: ''
|
||||
});
|
||||
|
||||
// Use the videos hook
|
||||
const {
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchVideos,
|
||||
publishVideo,
|
||||
deleteVideo
|
||||
} = useVideos();
|
||||
|
||||
// State for delete confirmation
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// Apply filters when they change
|
||||
useEffect(() => {
|
||||
const apiFilter: VideoFilterType = {};
|
||||
|
||||
if (filter.category !== 'all') {
|
||||
apiFilter.category = filter.category;
|
||||
}
|
||||
|
||||
if (filter.published !== 'all') {
|
||||
apiFilter.published = filter.published === 'true';
|
||||
}
|
||||
|
||||
if (filter.accessibility === 'accessible') {
|
||||
apiFilter.isAccessible = true;
|
||||
} else if (filter.accessibility === 'not-accessible') {
|
||||
apiFilter.isAccessible = false;
|
||||
} else if (filter.accessibility === 'subtitles') {
|
||||
apiFilter.hasSubtitles = true;
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
apiFilter.search = filter.search;
|
||||
}
|
||||
|
||||
fetchVideos(apiFilter);
|
||||
}, [filter, fetchVideos]);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; category: string; search: string }) => {
|
||||
const handleFilterChange = (newFilter: {
|
||||
category: string;
|
||||
published: string;
|
||||
accessibility: string;
|
||||
search: string
|
||||
}) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Function to get status badge styles
|
||||
const getStatusBadgeClasses = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200';
|
||||
case 'draft':
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
case 'processing':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
// Handle API errors
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-red-50 dark:bg-red-900 rounded-md">
|
||||
<h2 className="text-lg font-semibold text-red-700 dark:text-red-200">Error Loading Videos</h2>
|
||||
<p className="text-red-600 dark:text-red-300 mt-2">{error.message}</p>
|
||||
<button
|
||||
onClick={() => fetchVideos()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle publish/unpublish
|
||||
const handlePublishToggle = async (id: string, currentStatus: boolean) => {
|
||||
const result = await publishVideo(id, !currentStatus);
|
||||
|
||||
if (result) {
|
||||
// Could add a toast notification here
|
||||
alert(`Video ${!currentStatus ? 'published' : 'unpublished'} successfully`);
|
||||
} else {
|
||||
alert('Failed to update video status. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Video card component
|
||||
const VideoCard = ({ video }: { video: typeof allVideos[0] }) => (
|
||||
<div className="card overflow-hidden h-full flex flex-col">
|
||||
{/* Thumbnail with duration */}
|
||||
<div className="relative bg-gray-200 dark:bg-gray-700 aspect-video">
|
||||
{/* In a real app, this would be an actual image */}
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{video.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async (id: string) => {
|
||||
if (deleteConfirm === id) {
|
||||
// User confirmed deletion
|
||||
const success = await deleteVideo(id);
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white line-clamp-1">{video.title}</h3>
|
||||
<span className={`ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getStatusBadgeClasses(video.status)}`}>
|
||||
{video.status.charAt(0).toUpperCase() + video.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">{video.description}</p>
|
||||
<div className="mt-2 flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Uploaded: {formatDate(video.uploadDate)}</span>
|
||||
</div>
|
||||
|
||||
{/* Accessibility status */}
|
||||
<div className="mt-2 flex items-center space-x-2 text-xs">
|
||||
<span className={`px-2 py-0.5 rounded-full ${video.hasSubtitles ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{video.hasSubtitles ? 'Has Subtitles' : 'No Subtitles'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full ${video.hasTranscript ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{video.hasTranscript ? 'Has Transcript' : 'No Transcript'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200">
|
||||
{video.category.charAt(0).toUpperCase() + video.category.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<Link href={`/admin/videos/${video.id}`} className="text-primary hover:text-primary-dark text-sm">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/videos/${video.id}/edit`} className="text-primary hover:text-primary-dark text-sm">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Delete functionality would go here
|
||||
alert(`Delete video: ${video.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (success) {
|
||||
alert('Video deleted successfully');
|
||||
// Refresh the videos list
|
||||
fetchVideos();
|
||||
} else {
|
||||
alert('Failed to delete video. Please try again.');
|
||||
}
|
||||
|
||||
// Reset confirmation state
|
||||
setDeleteConfirm(null);
|
||||
} else {
|
||||
// Ask for confirmation
|
||||
setDeleteConfirm(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -290,18 +252,18 @@ export default function VideosPage() {
|
|||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Videos</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage ASL videos with subtitles and transcripts.
|
||||
Manage videos, add accessibility features, and control publishing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/videos/upload"
|
||||
href="/admin/videos/create"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Upload Video
|
||||
Add Video
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -310,15 +272,146 @@ export default function VideosPage() {
|
|||
<VideoFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Videos grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredVideos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{loading ? (
|
||||
<div className="col-span-3 py-12 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-500 dark:text-gray-400">Loading videos...</p>
|
||||
</div>
|
||||
) : videos.length > 0 ? (
|
||||
videos.map((video) => (
|
||||
<div key={video._id} className="card overflow-hidden flex flex-col">
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video bg-gray-200 dark:bg-gray-800 relative">
|
||||
{video.thumbnailPath ? (
|
||||
<img
|
||||
src={`/api/uploads/${video.thumbnailPath}`}
|
||||
alt={`Thumbnail for ${video.title}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-gray-400">No thumbnail available</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{formatDuration(video.duration)}
|
||||
</div>
|
||||
{video.hasSubtitles && (
|
||||
<div className="absolute top-2 left-2 bg-blue-500 text-white text-xs px-2 py-1 rounded">
|
||||
CC
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex-grow flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2">
|
||||
{video.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 line-clamp-2">
|
||||
{video.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
<span>Category: {video.category}</span>
|
||||
<span>Uploaded: {formatDate(video.uploadDate)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
video.published
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}>
|
||||
{video.published ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
video.isAccessible
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{video.isAccessible ? 'Accessible' : 'Not Accessible'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/admin/videos/${video._id}`}
|
||||
className="text-sm text-primary hover:text-primary-dark"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/videos/${video._id}/edit`}
|
||||
className="text-sm text-primary hover:text-primary-dark"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(video._id)}
|
||||
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{deleteConfirm === video._id ? 'Confirm Delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePublishToggle(video._id, video.published)}
|
||||
className={`text-sm ${
|
||||
video.published
|
||||
? 'text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300'
|
||||
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{video.published ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-3 py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No videos found matching the current filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredVideos.length === 0 && (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No videos found matching the current filters.</p>
|
||||
{/* Pagination (simplified for now) */}
|
||||
{pagination && pagination.pages > 1 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<nav className="inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<button
|
||||
disabled={pagination.page === 1}
|
||||
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium ${
|
||||
pagination.page === 1
|
||||
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
Page {pagination.page} of {pagination.pages}
|
||||
</span>
|
||||
<button
|
||||
disabled={pagination.page === pagination.pages}
|
||||
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium ${
|
||||
pagination.page === pagination.pages
|
||||
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
81
frontend/src/app/api/uploads/[...path]/route.ts
Normal file
81
frontend/src/app/api/uploads/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* API route to serve uploaded files
|
||||
* This allows us to serve files from the backend uploads directory
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
// Get the file path from the URL
|
||||
const pathParams = await Promise.resolve(params.path);
|
||||
const filePath = pathParams.join('/');
|
||||
|
||||
// Construct the absolute path to the file
|
||||
// This assumes the backend is in the same directory as the frontend
|
||||
// Check if filePath already starts with 'uploads/'
|
||||
const absolutePath = filePath.startsWith('uploads/')
|
||||
? path.join(process.cwd(), '..', 'backend', filePath)
|
||||
: path.join(process.cwd(), '..', 'backend', 'uploads', filePath);
|
||||
|
||||
console.log('Requested file path:', filePath);
|
||||
console.log('Absolute path:', absolutePath);
|
||||
|
||||
// Check if the file exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileBuffer = fs.readFileSync(absolutePath);
|
||||
|
||||
// Determine content type based on file extension
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
let contentType = 'application/octet-stream'; // Default content type
|
||||
|
||||
// Set content type based on file extension
|
||||
switch (ext) {
|
||||
case '.mp4':
|
||||
contentType = 'video/mp4';
|
||||
break;
|
||||
case '.webm':
|
||||
contentType = 'video/webm';
|
||||
break;
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
contentType = 'image/jpeg';
|
||||
break;
|
||||
case '.png':
|
||||
contentType = 'image/png';
|
||||
break;
|
||||
case '.gif':
|
||||
contentType = 'image/gif';
|
||||
break;
|
||||
case '.vtt':
|
||||
contentType = 'text/vtt';
|
||||
break;
|
||||
case '.srt':
|
||||
contentType = 'text/plain';
|
||||
break;
|
||||
case '.txt':
|
||||
contentType = 'text/plain';
|
||||
break;
|
||||
}
|
||||
|
||||
// Return the file with appropriate headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
|
||||
'Cache-Control': 'public, max-age=86400' // Cache for 1 day
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error serving file:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -137,3 +137,33 @@
|
|||
main {
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
/* Custom file input styling */
|
||||
.file-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.file-input::file-selector-button {
|
||||
margin-right: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-input::file-selector-button:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-input {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
603
frontend/src/components/admin/MemberForm.tsx
Normal file
603
frontend/src/components/admin/MemberForm.tsx
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from 'axios';
|
||||
import { Member } from '../../hooks/useMembers';
|
||||
|
||||
interface MemberFormProps {
|
||||
initialData?: Partial<Member>;
|
||||
onSubmit: (data: any) => Promise<Member | null>;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const MemberForm: React.FC<MemberFormProps> = ({
|
||||
initialData = {},
|
||||
onSubmit,
|
||||
isEdit = false
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: {
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: ''
|
||||
},
|
||||
membershipType: 'regular',
|
||||
joinDate: '',
|
||||
expirationDate: '',
|
||||
status: 'pending',
|
||||
notificationPreference: 'email',
|
||||
boardMember: false,
|
||||
boardPosition: '',
|
||||
emergencyContact: {
|
||||
name: '',
|
||||
relationship: '',
|
||||
phone: ''
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize form with initial data if provided
|
||||
useEffect(() => {
|
||||
if (initialData && Object.keys(initialData).length > 0) {
|
||||
const formattedData = {
|
||||
...initialData,
|
||||
joinDate: initialData.joinDate ? new Date(initialData.joinDate).toISOString().split('T')[0] : '',
|
||||
expirationDate: initialData.expirationDate ? new Date(initialData.expirationDate).toISOString().split('T')[0] : '',
|
||||
address: {
|
||||
street: initialData.address?.street || '',
|
||||
city: initialData.address?.city || '',
|
||||
state: initialData.address?.state || '',
|
||||
zip: initialData.address?.zip || ''
|
||||
},
|
||||
emergencyContact: {
|
||||
name: initialData.emergencyContact?.name || '',
|
||||
relationship: initialData.emergencyContact?.relationship || '',
|
||||
phone: initialData.emergencyContact?.phone || ''
|
||||
}
|
||||
};
|
||||
|
||||
setFormData(prevData => ({
|
||||
...prevData,
|
||||
...formattedData
|
||||
}));
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Handle input changes
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
// Handle checkbox inputs
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle nested fields (address, emergencyContact)
|
||||
if (name.includes('.')) {
|
||||
const [parent, child] = name.split('.');
|
||||
setFormData(prev => {
|
||||
if (parent === 'address') {
|
||||
return {
|
||||
...prev,
|
||||
address: {
|
||||
...prev.address,
|
||||
[child]: value
|
||||
}
|
||||
};
|
||||
} else if (parent === 'emergencyContact') {
|
||||
return {
|
||||
...prev,
|
||||
emergencyContact: {
|
||||
...prev.emergencyContact,
|
||||
[child]: value
|
||||
}
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular fields
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
// Required fields
|
||||
if (!formData.firstName.trim()) {
|
||||
errors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.lastName.trim()) {
|
||||
errors.lastName = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
if (!formData.membershipType) {
|
||||
errors.membershipType = 'Membership type is required';
|
||||
}
|
||||
|
||||
// Board position required if board member is true
|
||||
if (formData.boardMember && !formData.boardPosition.trim()) {
|
||||
errors.boardPosition = 'Board position is required for board members';
|
||||
}
|
||||
|
||||
// Phone number format validation (if provided)
|
||||
if (formData.phone && !/^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/.test(formData.phone)) {
|
||||
errors.phone = 'Please enter a valid phone number';
|
||||
}
|
||||
|
||||
// ZIP code format validation (if provided)
|
||||
if (formData.address.zip && !/^\d{5}(-\d{4})?$/.test(formData.address.zip)) {
|
||||
errors['address.zip'] = 'Please enter a valid ZIP code';
|
||||
}
|
||||
|
||||
// Emergency contact phone validation (if provided)
|
||||
if (formData.emergencyContact.phone &&
|
||||
!/^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/.test(formData.emergencyContact.phone)) {
|
||||
errors['emergencyContact.phone'] = 'Please enter a valid phone number';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Format dates for API
|
||||
const formattedData = {
|
||||
...formData,
|
||||
joinDate: formData.joinDate ? new Date(formData.joinDate).toISOString() : undefined,
|
||||
expirationDate: formData.expirationDate ? new Date(formData.expirationDate).toISOString() : undefined
|
||||
};
|
||||
|
||||
// Submit form data
|
||||
const result = await onSubmit(formattedData);
|
||||
|
||||
if (result) {
|
||||
// Redirect to member details page or members list
|
||||
if (isEdit) {
|
||||
router.push(`/admin/members/${result._id}`);
|
||||
} else {
|
||||
router.push('/admin/members');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Handle different types of errors
|
||||
if (axios.isAxiosError(err)) {
|
||||
// Handle Axios errors
|
||||
const statusCode = err.response?.status;
|
||||
const errorMessage = err.response?.data?.error?.message || err.message;
|
||||
|
||||
if (statusCode === 401) {
|
||||
setError('Authentication error: You are not authorized to perform this action.');
|
||||
} else if (statusCode === 400) {
|
||||
setError(`Validation error: ${errorMessage}`);
|
||||
} else if (statusCode === 500) {
|
||||
setError('Server error: The server encountered an error. Please try again later.');
|
||||
} else {
|
||||
setError(`Error: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
// Handle other errors
|
||||
setError('Failed to save member. Please try again.');
|
||||
}
|
||||
console.error('Error saving member:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Personal Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.firstName ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors.firstName && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.lastName ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors.lastName && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.email ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors.email && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.phone ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Address</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="address.street" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Street
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address.street"
|
||||
name="address.street"
|
||||
value={formData.address.street}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address.city" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address.city"
|
||||
name="address.city"
|
||||
value={formData.address.city}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="address.state" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address.state"
|
||||
name="address.state"
|
||||
value={formData.address.state}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address.zip" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address.zip"
|
||||
name="address.zip"
|
||||
value={formData.address.zip}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors['address.zip'] ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors['address.zip'] && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors['address.zip']}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Membership Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Membership Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="membershipType" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Membership Type *
|
||||
</label>
|
||||
<select
|
||||
id="membershipType"
|
||||
name="membershipType"
|
||||
value={formData.membershipType}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.membershipType ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
>
|
||||
<option value="regular">Regular</option>
|
||||
<option value="lifetime">Lifetime</option>
|
||||
<option value="honorary">Honorary</option>
|
||||
</select>
|
||||
{formErrors.membershipType && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.membershipType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="joinDate" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Join Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="joinDate"
|
||||
name="joinDate"
|
||||
value={formData.joinDate}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="expirationDate" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="expirationDate"
|
||||
name="expirationDate"
|
||||
value={formData.expirationDate}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notificationPreference" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Notification Preference
|
||||
</label>
|
||||
<select
|
||||
id="notificationPreference"
|
||||
name="notificationPreference"
|
||||
value={formData.notificationPreference}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board Member Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Board Member Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="boardMember"
|
||||
name="boardMember"
|
||||
checked={formData.boardMember}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="boardMember" className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Board Member
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.boardMember && (
|
||||
<div>
|
||||
<label htmlFor="boardPosition" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Board Position *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="boardPosition"
|
||||
name="boardPosition"
|
||||
value={formData.boardPosition}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.boardPosition ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors.boardPosition && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.boardPosition}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency Contact */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Emergency Contact</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="emergencyContact.name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="emergencyContact.name"
|
||||
name="emergencyContact.name"
|
||||
value={formData.emergencyContact.name}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="emergencyContact.relationship" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Relationship
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="emergencyContact.relationship"
|
||||
name="emergencyContact.relationship"
|
||||
value={formData.emergencyContact.relationship}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="emergencyContact.phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="emergencyContact.phone"
|
||||
name="emergencyContact.phone"
|
||||
value={formData.emergencyContact.phone}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors['emergencyContact.phone'] ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
/>
|
||||
{formErrors['emergencyContact.phone'] && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors['emergencyContact.phone']}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>Save</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberForm;
|
||||
601
frontend/src/components/admin/VideoForm.tsx
Normal file
601
frontend/src/components/admin/VideoForm.tsx
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Video } from '../../hooks/useVideos';
|
||||
|
||||
interface VideoFormProps {
|
||||
initialData?: Partial<Video>;
|
||||
onSubmit: (data: FormData) => Promise<Video | null>;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const VideoForm: React.FC<VideoFormProps> = ({
|
||||
initialData = {},
|
||||
onSubmit,
|
||||
isEdit = false
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
|
||||
// File input refs
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const thumbnailInputRef = useRef<HTMLInputElement>(null);
|
||||
const subtitlesInputRef = useRef<HTMLInputElement>(null);
|
||||
const transcriptInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'general',
|
||||
published: false,
|
||||
hasSubtitles: false,
|
||||
hasTranscript: false
|
||||
});
|
||||
|
||||
// File state
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||
const [subtitlesFile, setSubtitlesFile] = useState<File | null>(null);
|
||||
const [transcriptFile, setTranscriptFile] = useState<File | null>(null);
|
||||
|
||||
// Preview state
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||
const [videoDuration, setVideoDuration] = useState<number>(0);
|
||||
const [videoName, setVideoName] = useState<string>('');
|
||||
|
||||
// Initialize form with initial data if provided
|
||||
useEffect(() => {
|
||||
if (initialData && Object.keys(initialData).length > 0) {
|
||||
// Set basic form data
|
||||
setFormData({
|
||||
title: initialData.title || '',
|
||||
description: initialData.description || '',
|
||||
category: initialData.category || 'general',
|
||||
published: initialData.published || false,
|
||||
hasSubtitles: initialData.hasSubtitles || false,
|
||||
hasTranscript: initialData.hasTranscript || false
|
||||
});
|
||||
|
||||
// Set video duration if available
|
||||
if (initialData.duration) {
|
||||
setVideoDuration(initialData.duration);
|
||||
}
|
||||
|
||||
// Set video name if available
|
||||
if (initialData.originalFilename) {
|
||||
setVideoName(initialData.originalFilename);
|
||||
}
|
||||
|
||||
// Set preview URLs if available
|
||||
if (initialData.videoPath) {
|
||||
setVideoPreview(`/api/uploads/${initialData.videoPath}`);
|
||||
}
|
||||
|
||||
if (initialData.thumbnailPath) {
|
||||
setThumbnailPreview(`/api/uploads/${initialData.thumbnailPath}`);
|
||||
}
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Handle input changes for text fields
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target as HTMLInputElement;
|
||||
|
||||
// Handle checkbox inputs
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular fields
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Clear error when user types
|
||||
if (formErrors[name]) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
[name]: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file input changes
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, files } = e.target;
|
||||
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Handle different file types
|
||||
switch (name) {
|
||||
case 'video':
|
||||
setVideoFile(file);
|
||||
setVideoName(file.name);
|
||||
|
||||
// Create object URL for preview
|
||||
const videoUrl = URL.createObjectURL(file);
|
||||
setVideoPreview(videoUrl);
|
||||
|
||||
// Get video duration
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
setVideoDuration(video.duration);
|
||||
URL.revokeObjectURL(video.src); // Clean up
|
||||
};
|
||||
video.src = videoUrl;
|
||||
|
||||
// Clear error
|
||||
if (formErrors.video) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
video: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thumbnail':
|
||||
setThumbnailFile(file);
|
||||
setThumbnailPreview(URL.createObjectURL(file));
|
||||
|
||||
// Clear error
|
||||
if (formErrors.thumbnail) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
thumbnail: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'subtitles':
|
||||
setSubtitlesFile(file);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
hasSubtitles: true
|
||||
}));
|
||||
|
||||
// Clear error
|
||||
if (formErrors.subtitles) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
subtitles: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'transcript':
|
||||
setTranscriptFile(file);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
hasTranscript: true
|
||||
}));
|
||||
|
||||
// Clear error
|
||||
if (formErrors.transcript) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
transcript: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Format duration for display (MM:SS)
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
// Required fields
|
||||
if (!formData.title.trim()) {
|
||||
errors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
errors.description = 'Description is required';
|
||||
}
|
||||
|
||||
if (!videoFile && !initialData.videoPath) {
|
||||
errors.video = 'Video file is required';
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
errors.category = 'Category is required';
|
||||
}
|
||||
|
||||
// Validate file types
|
||||
if (videoFile && !videoFile.type.startsWith('video/')) {
|
||||
errors.video = 'Please upload a valid video file';
|
||||
}
|
||||
|
||||
if (thumbnailFile && !thumbnailFile.type.startsWith('image/')) {
|
||||
errors.thumbnail = 'Please upload a valid image file';
|
||||
}
|
||||
|
||||
if (subtitlesFile && !subtitlesFile.name.endsWith('.vtt') && !subtitlesFile.name.endsWith('.srt')) {
|
||||
errors.subtitles = 'Please upload a valid subtitle file (.vtt or .srt)';
|
||||
}
|
||||
|
||||
if (transcriptFile && !transcriptFile.name.endsWith('.txt') && transcriptFile.type !== 'text/plain') {
|
||||
errors.transcript = 'Please upload a valid text file';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create FormData object
|
||||
const formDataObj = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formDataObj.append('title', formData.title);
|
||||
formDataObj.append('description', formData.description);
|
||||
formDataObj.append('category', formData.category);
|
||||
formDataObj.append('published', formData.published.toString());
|
||||
|
||||
// Add files if they exist
|
||||
if (videoFile) {
|
||||
formDataObj.append('video', videoFile);
|
||||
}
|
||||
|
||||
if (thumbnailFile) {
|
||||
formDataObj.append('thumbnail', thumbnailFile);
|
||||
}
|
||||
|
||||
if (subtitlesFile) {
|
||||
formDataObj.append('subtitles', subtitlesFile);
|
||||
formDataObj.append('subtitlesLanguage', 'en'); // Default to English
|
||||
}
|
||||
|
||||
if (transcriptFile) {
|
||||
formDataObj.append('transcript', transcriptFile);
|
||||
formDataObj.append('transcriptLanguage', 'en'); // Default to English
|
||||
}
|
||||
|
||||
// Submit form data
|
||||
const result = await onSubmit(formDataObj);
|
||||
|
||||
if (result) {
|
||||
// Redirect to video details page or videos list
|
||||
if (isEdit) {
|
||||
router.push(`/admin/videos/${result._id}`);
|
||||
} else {
|
||||
router.push('/admin/videos');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle error
|
||||
setError(err.message || 'Failed to save video. Please try again.');
|
||||
console.error('Error saving video:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Basic Information</h2>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.title ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="Video title"
|
||||
/>
|
||||
{formErrors.title && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.description ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
placeholder="Video description"
|
||||
/>
|
||||
{formErrors.description && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="video" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Video File *
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="video"
|
||||
name="video"
|
||||
ref={videoInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="video/*"
|
||||
className={`file-input ${formErrors.video ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.video && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.video}</p>
|
||||
)}
|
||||
|
||||
{/* Video info */}
|
||||
{(videoPreview || videoName) && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{videoName && <span className="font-medium">File: </span>}{videoName}
|
||||
</p>
|
||||
{videoDuration > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">Duration: </span>{formatDuration(videoDuration)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video preview */}
|
||||
{videoPreview && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Video Preview</h4>
|
||||
<div className="aspect-video bg-gray-200 dark:bg-gray-800 rounded-md overflow-hidden">
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.category ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'} dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm`}
|
||||
>
|
||||
<option value="social">Social</option>
|
||||
<option value="educational">Educational</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
{formErrors.category && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="thumbnail" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Thumbnail Image <span className="text-xs text-gray-500">(Optional - will be auto-generated if not provided)</span>
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="thumbnail"
|
||||
name="thumbnail"
|
||||
ref={thumbnailInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className={`file-input ${formErrors.thumbnail ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.thumbnail && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.thumbnail}</p>
|
||||
)}
|
||||
|
||||
{/* Thumbnail preview */}
|
||||
{thumbnailPreview && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={thumbnailPreview}
|
||||
alt="Thumbnail preview"
|
||||
className="h-24 object-cover rounded-md"
|
||||
onError={() => setFormErrors({...formErrors, thumbnail: 'Unable to load thumbnail image'})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="published"
|
||||
name="published"
|
||||
checked={formData.published}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="published" className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Published
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accessibility Features */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Accessibility Features</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="subtitles" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subtitles File (VTT or SRT)
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="subtitles"
|
||||
name="subtitles"
|
||||
ref={subtitlesInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".vtt,.srt"
|
||||
className={`file-input ${formErrors.subtitles ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.subtitles && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.subtitles}</p>
|
||||
)}
|
||||
{subtitlesFile && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">File: </span>{subtitlesFile.name}
|
||||
</p>
|
||||
)}
|
||||
{initialData.subtitlesPath && !subtitlesFile && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">Current subtitles file: </span>
|
||||
{initialData.subtitlesPath.split('/').pop()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="transcript" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Transcript File (TXT)
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="transcript"
|
||||
name="transcript"
|
||||
ref={transcriptInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".txt,text/plain"
|
||||
className={`file-input ${formErrors.transcript ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.transcript && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.transcript}</p>
|
||||
)}
|
||||
{transcriptFile && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">File: </span>{transcriptFile.name}
|
||||
</p>
|
||||
)}
|
||||
{initialData.transcriptPath && !transcriptFile && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">Current transcript file: </span>
|
||||
{initialData.transcriptPath.split('/').pop()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900 rounded-md">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-200">
|
||||
<span className="font-medium">Accessibility Status: </span>
|
||||
{formData.hasSubtitles || formData.hasTranscript || subtitlesFile || transcriptFile ?
|
||||
'This video will be marked as accessible' :
|
||||
'This video will not be marked as accessible'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-blue-600 dark:text-blue-300">
|
||||
Videos are automatically marked as accessible when subtitles or transcript files are provided.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>Save</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoForm;
|
||||
282
frontend/src/hooks/useMembers.ts
Normal file
282
frontend/src/hooks/useMembers.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Define Member interface
|
||||
export interface Member {
|
||||
_id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
address?: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
};
|
||||
membershipType: 'regular' | 'lifetime' | 'honorary';
|
||||
joinDate: string;
|
||||
expirationDate?: string;
|
||||
status: 'active' | 'expired' | 'pending';
|
||||
notificationPreference?: 'email' | 'sms' | 'both';
|
||||
lastRenewalDate?: string;
|
||||
boardMember: boolean;
|
||||
boardPosition?: string;
|
||||
emergencyContact?: {
|
||||
name: string;
|
||||
relationship: string;
|
||||
phone: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Define filter interface
|
||||
export interface MemberFilter {
|
||||
status?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
boardMember?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Define pagination interface
|
||||
export interface Pagination {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// Define analytics interface
|
||||
export interface MemberAnalytics {
|
||||
statusCounts: Record<string, number>;
|
||||
typeCounts: Record<string, number>;
|
||||
boardMemberCount: number;
|
||||
expiringCount: number;
|
||||
recentJoinsCount: number;
|
||||
totalMembers: number;
|
||||
}
|
||||
|
||||
// Define bulk action interface
|
||||
export interface BulkActionResult {
|
||||
message: string;
|
||||
affected: number;
|
||||
}
|
||||
|
||||
// Define hook return interface
|
||||
export interface UseMembersReturn {
|
||||
members: Member[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
pagination: Pagination | null;
|
||||
analytics: MemberAnalytics | null;
|
||||
fetchMembers: (filters?: MemberFilter) => Promise<void>;
|
||||
getMemberById: (id: string) => Promise<Member | null>;
|
||||
createMember: (memberData: Omit<Member, '_id' | 'createdAt' | 'updatedAt'>) => Promise<Member | null>;
|
||||
updateMember: (id: string, memberData: Partial<Member>) => Promise<Member | null>;
|
||||
deleteMember: (id: string) => Promise<boolean>;
|
||||
bulkAction: (action: string, memberIds: string[]) => Promise<BulkActionResult | null>;
|
||||
fetchAnalytics: () => Promise<MemberAnalytics | null>;
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
/**
|
||||
* Hook for managing members data
|
||||
*/
|
||||
export function useMembers(): UseMembersReturn {
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||
const [analytics, setAnalytics] = useState<MemberAnalytics | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch members with optional filtering
|
||||
*/
|
||||
const fetchMembers = useCallback(async (filters?: MemberFilter) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build query string from filters
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters) {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_URL}/members?${queryParams.toString()}`);
|
||||
setMembers(response.data.members);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Error fetching members:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch members'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get a single member by ID
|
||||
*/
|
||||
const getMemberById = useCallback(async (id: string): Promise<Member | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/members/${id}`);
|
||||
return response.data.member;
|
||||
} catch (err) {
|
||||
console.error(`Error fetching member with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new member
|
||||
*/
|
||||
const createMember = useCallback(async (memberData: Omit<Member, '_id' | 'createdAt' | 'updatedAt'>): Promise<Member | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/members`, memberData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh members list after creating
|
||||
fetchMembers();
|
||||
|
||||
return response.data.member;
|
||||
} catch (err) {
|
||||
console.error('Error creating member:', err);
|
||||
// Rethrow the error so it can be caught and handled by the component
|
||||
throw err;
|
||||
}
|
||||
}, [fetchMembers]);
|
||||
|
||||
/**
|
||||
* Update an existing member
|
||||
*/
|
||||
const updateMember = useCallback(async (id: string, memberData: Partial<Member>): Promise<Member | null> => {
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/members/${id}`, memberData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setMembers(prevMembers =>
|
||||
prevMembers.map(member =>
|
||||
member._id === id ? { ...member, ...response.data.member } : member
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.member;
|
||||
} catch (err) {
|
||||
console.error(`Error updating member with ID ${id}:`, err);
|
||||
// Rethrow the error so it can be caught and handled by the component
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a member
|
||||
*/
|
||||
const deleteMember = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/members/${id}`, {
|
||||
headers: {
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setMembers(prevMembers => prevMembers.filter(member => member._id !== id));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error deleting member with ID ${id}:`, err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Perform bulk actions on members
|
||||
*/
|
||||
const bulkAction = useCallback(async (action: string, memberIds: string[]): Promise<BulkActionResult | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/members/bulk-action`,
|
||||
{ action, memberIds },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Refresh members list after bulk action
|
||||
fetchMembers();
|
||||
|
||||
return {
|
||||
message: response.data.message,
|
||||
affected: response.data.affected
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error performing bulk action ${action}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, [fetchMembers]);
|
||||
|
||||
/**
|
||||
* Fetch member analytics
|
||||
*/
|
||||
const fetchAnalytics = useCallback(async (): Promise<MemberAnalytics | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/members/analytics/stats`, {
|
||||
headers: {
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const analyticsData = response.data.analytics;
|
||||
setAnalytics(analyticsData);
|
||||
return analyticsData;
|
||||
} catch (err) {
|
||||
console.error('Error fetching member analytics:', err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load members on initial render
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, [fetchMembers]);
|
||||
|
||||
return {
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
analytics,
|
||||
fetchMembers,
|
||||
getMemberById,
|
||||
createMember,
|
||||
updateMember,
|
||||
deleteMember,
|
||||
bulkAction,
|
||||
fetchAnalytics
|
||||
};
|
||||
}
|
||||
336
frontend/src/hooks/useVideos.ts
Normal file
336
frontend/src/hooks/useVideos.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Define Video interface
|
||||
export interface Video {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
videoPath: string;
|
||||
thumbnailPath?: string;
|
||||
duration: number; // in seconds
|
||||
category: string;
|
||||
uploadDate: Date;
|
||||
hasSubtitles: boolean;
|
||||
subtitlesPath?: string;
|
||||
subtitlesLanguage?: string;
|
||||
hasTranscript: boolean;
|
||||
transcriptPath?: string;
|
||||
transcriptLanguage?: string;
|
||||
isAccessible: boolean;
|
||||
viewCount: number;
|
||||
published: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
originalFilename?: string;
|
||||
}
|
||||
|
||||
// Define filter interface
|
||||
export interface VideoFilter {
|
||||
category?: string;
|
||||
search?: string;
|
||||
hasSubtitles?: boolean;
|
||||
isAccessible?: boolean;
|
||||
published?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Define pagination interface
|
||||
export interface Pagination {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// Define hook return interface
|
||||
export interface UseVideosReturn {
|
||||
videos: Video[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
pagination: Pagination | null;
|
||||
fetchVideos: (filters?: VideoFilter) => Promise<void>;
|
||||
getVideoById: (id: string) => Promise<Video | null>;
|
||||
createVideo: (formData: FormData) => Promise<Video | null>;
|
||||
updateVideo: (id: string, videoData: Partial<Video>) => Promise<Video | null>;
|
||||
deleteVideo: (id: string) => Promise<boolean>;
|
||||
uploadSubtitles: (id: string, formData: FormData) => Promise<Video | null>;
|
||||
uploadThumbnail: (id: string, formData: FormData) => Promise<Video | null>;
|
||||
uploadTranscript: (id: string, formData: FormData) => Promise<Video | null>;
|
||||
publishVideo: (id: string, published: boolean) => Promise<Video | null>;
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
/**
|
||||
* Hook for managing videos data
|
||||
*/
|
||||
export function useVideos(): UseVideosReturn {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch videos with optional filtering
|
||||
*/
|
||||
const fetchVideos = useCallback(async (filters?: VideoFilter) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build query string from filters
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters) {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_URL}/videos?${queryParams.toString()}`);
|
||||
setVideos(response.data.videos);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Error fetching videos:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch videos'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get a single video by ID
|
||||
*/
|
||||
const getVideoById = useCallback(async (id: string): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos/${id}`);
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error(`Error fetching video with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new video
|
||||
*/
|
||||
const createVideo = useCallback(async (formData: FormData): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/videos`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh videos list after creating
|
||||
fetchVideos();
|
||||
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error('Error creating video:', err);
|
||||
// Rethrow the error so it can be caught and handled by the component
|
||||
throw err;
|
||||
}
|
||||
}, [fetchVideos]);
|
||||
|
||||
/**
|
||||
* Update an existing video
|
||||
*/
|
||||
const updateVideo = useCallback(async (id: string, videoData: Partial<Video>): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, videoData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setVideos(prevVideos =>
|
||||
prevVideos.map(video =>
|
||||
video._id === id ? { ...video, ...response.data.video } : video
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error(`Error updating video with ID ${id}:`, err);
|
||||
// Rethrow the error so it can be caught and handled by the component
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a video
|
||||
*/
|
||||
const deleteVideo = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/videos/${id}`, {
|
||||
headers: {
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setVideos(prevVideos => prevVideos.filter(video => video._id !== id));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error deleting video with ID ${id}:`, err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Upload subtitles for a video
|
||||
*/
|
||||
const uploadSubtitles = useCallback(async (id: string, formData: FormData): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/videos/${id}/subtitles`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setVideos(prevVideos =>
|
||||
prevVideos.map(video =>
|
||||
video._id === id ? { ...video, ...response.data.video } : video
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error(`Error uploading subtitles for video with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Upload thumbnail for a video
|
||||
*/
|
||||
const uploadThumbnail = useCallback(async (id: string, formData: FormData): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/videos/${id}/thumbnail`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setVideos(prevVideos =>
|
||||
prevVideos.map(video =>
|
||||
video._id === id ? { ...video, ...response.data.video } : video
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error(`Error uploading thumbnail for video with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Upload transcript for a video
|
||||
*/
|
||||
const uploadTranscript = useCallback(async (id: string, formData: FormData): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/videos/${id}/transcript`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setVideos(prevVideos =>
|
||||
prevVideos.map(video =>
|
||||
video._id === id ? { ...video, ...response.data.video } : video
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error(`Error uploading transcript for video with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Publish or unpublish a video
|
||||
*/
|
||||
const publishVideo = useCallback(async (id: string, published: boolean): Promise<Video | null> => {
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${API_URL}/videos/${id}/publish`,
|
||||
{ published },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setVideos(prevVideos =>
|
||||
prevVideos.map(video =>
|
||||
video._id === id ? { ...video, ...response.data.video } : video
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.video;
|
||||
} catch (err) {
|
||||
console.error(`Error ${published ? 'publishing' : 'unpublishing'} video with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load videos on initial render
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [fetchVideos]);
|
||||
|
||||
return {
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchVideos,
|
||||
getVideoById,
|
||||
createVideo,
|
||||
updateVideo,
|
||||
deleteVideo,
|
||||
uploadSubtitles,
|
||||
uploadThumbnail,
|
||||
uploadTranscript,
|
||||
publishVideo
|
||||
};
|
||||
}
|
||||
|
|
@ -21,7 +21,11 @@
|
|||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue