This commit simplifies the document management system to improve usability and user experience:
- Model changes: - Remove 'category' field from Document model - Replace complex 'status' field with simple 'isPublic' boolean flag - Enhance document types to be more specific and user-friendly - UI improvements: - Update DocumentForm component to use simplified model - Improve document listing page with clearer public/private status - Enhance document detail page with better PDF preview using <object> tag - Fix document edit page to work with new model - API fixes: - Fix uploads API route to properly handle PDF files - Add proper content types for various document formats - Fix params.path handling to prevent errors - UX enhancements: - Simplify form with clearer options - Make public/private toggle more intuitive - Improve document preview to avoid unwanted downloads - Enhance file type handling for better compatibility - Documentation: - Update memory bank (activeContext.md and progress.md) This change improves the overall user experience by making the document management system more intuitive while maintaining all necessary functionality.
This commit is contained in:
parent
4221cbfcb3
commit
ea521e92c6
18 changed files with 3142 additions and 428 deletions
542
backend/src/controllers/documentController.ts
Normal file
542
backend/src/controllers/documentController.ts
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
import { Request, Response } from 'express';
|
||||
import mongoose from 'mongoose';
|
||||
import Document from '../models/Document';
|
||||
import { getUploadedFilePaths } from '../middleware/upload';
|
||||
import { DocumentProcessor } from '../services/documentProcessor';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Get all documents with optional filtering
|
||||
*/
|
||||
export const getDocuments = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
documentType,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
isPublic
|
||||
} = req.query;
|
||||
|
||||
// Build query
|
||||
const query: any = {};
|
||||
|
||||
// Filter by document type if provided
|
||||
if (documentType && documentType !== 'all') {
|
||||
query.documentType = documentType;
|
||||
}
|
||||
|
||||
// Filter by public/private status if provided
|
||||
if (isPublic !== undefined) {
|
||||
query.isPublic = isPublic === 'true';
|
||||
}
|
||||
|
||||
// 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 documents = await Document.find(query)
|
||||
.sort({ uploadDate: -1 }) // Sort by upload date, newest first
|
||||
.skip(skip)
|
||||
.limit(Number(limit));
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await Document.countDocuments(query);
|
||||
|
||||
return res.status(200).json({
|
||||
documents,
|
||||
pagination: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
pages: Math.ceil(total / Number(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching documents:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch documents'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single document by ID
|
||||
*/
|
||||
export const getDocumentById = 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 document ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find document
|
||||
const document = await Document.findById(id);
|
||||
|
||||
// Check if document exists
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Document not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
console.error('Error fetching document:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch document'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new document
|
||||
*/
|
||||
export const createDocument = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Get uploaded file paths
|
||||
const { documentPath, textVersionPath } = getUploadedFilePaths(req);
|
||||
|
||||
// Check if document file was uploaded
|
||||
if (!documentPath) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Document file is required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get form data
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
documentType,
|
||||
meetingDate,
|
||||
isPublic
|
||||
} = req.body;
|
||||
|
||||
// Get file size
|
||||
let fileSize = 0;
|
||||
try {
|
||||
fileSize = await DocumentProcessor.getFileSize(documentPath);
|
||||
} catch (error) {
|
||||
console.error('Error getting file size:', error);
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Failed to process document file. Please check the file format.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get file type
|
||||
const fileType = DocumentProcessor.getFileType(documentPath);
|
||||
|
||||
// Generate text version if not provided and file is PDF
|
||||
let finalTextVersionPath = textVersionPath;
|
||||
if (!finalTextVersionPath && fileType === 'pdf') {
|
||||
console.log('No text version provided, generating one automatically');
|
||||
try {
|
||||
// Ensure textversions directory exists
|
||||
const textVersionsDir = path.join(process.cwd(), 'uploads/textversions');
|
||||
console.log('Text versions directory:', textVersionsDir);
|
||||
await fs.ensureDir(textVersionsDir);
|
||||
|
||||
// Log document path
|
||||
console.log('Document path for text version generation:', documentPath);
|
||||
console.log('Document path exists:', await fs.pathExists(documentPath));
|
||||
|
||||
// Generate text version
|
||||
console.log('Calling DocumentProcessor.generateTextVersion');
|
||||
finalTextVersionPath = await DocumentProcessor.generateTextVersion(documentPath, textVersionsDir);
|
||||
console.log('Generated text version at:', finalTextVersionPath);
|
||||
console.log('Text version exists:', await fs.pathExists(finalTextVersionPath));
|
||||
} catch (error) {
|
||||
console.error('Error generating text version:', error);
|
||||
// Continue without text version if generation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Check if document has text version
|
||||
const hasTextVersion = !!finalTextVersionPath;
|
||||
|
||||
// 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(documentPath);
|
||||
|
||||
// Convert absolute paths to relative paths for storage
|
||||
const relativeDocumentPath = DocumentProcessor.getRelativePath(documentPath);
|
||||
const relativeTextVersionPath = finalTextVersionPath ? DocumentProcessor.getRelativePath(finalTextVersionPath) : undefined;
|
||||
|
||||
// Create new document
|
||||
const document = new Document({
|
||||
title,
|
||||
description,
|
||||
filePath: relativeDocumentPath,
|
||||
originalFilename,
|
||||
fileType,
|
||||
fileSize,
|
||||
documentType,
|
||||
isPublic: isPublic === 'true' || isPublic === true,
|
||||
meetingDate: meetingDate ? new Date(meetingDate) : undefined,
|
||||
uploadDate: new Date(),
|
||||
lastModified: new Date(),
|
||||
accessibilityChecked: false,
|
||||
hasTextVersion,
|
||||
textVersionPath: relativeTextVersionPath
|
||||
});
|
||||
|
||||
// Save document to database
|
||||
await document.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Document created successfully',
|
||||
document
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating document:', 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 document'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing document
|
||||
*/
|
||||
export const updateDocument = 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 document ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update lastModified date
|
||||
updateData.lastModified = new Date();
|
||||
|
||||
// No need to parse tags anymore
|
||||
|
||||
// Find and update document
|
||||
const document = await Document.findByIdAndUpdate(
|
||||
id,
|
||||
updateData,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
// Check if document exists
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Document not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Document updated successfully',
|
||||
document
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating document:', 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 document'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
export const deleteDocument = 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 document ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find document to get file paths
|
||||
const document = await Document.findById(id);
|
||||
|
||||
// Check if document exists
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Document not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete document files
|
||||
if (document.filePath) {
|
||||
await DocumentProcessor.deleteFile(DocumentProcessor.getAbsolutePath(document.filePath));
|
||||
}
|
||||
if (document.textVersionPath) {
|
||||
await DocumentProcessor.deleteFile(DocumentProcessor.getAbsolutePath(document.textVersionPath));
|
||||
}
|
||||
|
||||
// Delete document from database
|
||||
await Document.findByIdAndDelete(id);
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Document deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting document:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete document'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload text version for a document
|
||||
*/
|
||||
export const uploadTextVersion = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get uploaded text version file
|
||||
const { textVersionPath } = getUploadedFilePaths(req);
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid document ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!textVersionPath) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Text version file is required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find document to get old text version path
|
||||
const existingDocument = await Document.findById(id);
|
||||
|
||||
// Check if document exists
|
||||
if (!existingDocument) {
|
||||
// Delete uploaded file if document not found
|
||||
await DocumentProcessor.deleteFile(textVersionPath);
|
||||
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Document not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete old text version if exists
|
||||
if (existingDocument.textVersionPath) {
|
||||
const oldTextVersionPath = DocumentProcessor.getAbsolutePath(existingDocument.textVersionPath);
|
||||
await DocumentProcessor.deleteFile(oldTextVersionPath);
|
||||
}
|
||||
|
||||
// Convert absolute path to relative path
|
||||
const relativeTextVersionPath = DocumentProcessor.getRelativePath(textVersionPath);
|
||||
|
||||
// Update document with new text version
|
||||
const document = await Document.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
textVersionPath: relativeTextVersionPath,
|
||||
hasTextVersion: true,
|
||||
accessibilityChecked: true,
|
||||
lastModified: new Date()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Text version uploaded successfully',
|
||||
document
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading text version:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to upload text version'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update document public status
|
||||
*/
|
||||
export const updatePublicStatus = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { isPublic } = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid document ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate isPublic is a boolean
|
||||
if (typeof isPublic !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid isPublic value. Must be a boolean.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and update document
|
||||
const document = await Document.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
isPublic,
|
||||
lastModified: new Date()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// Check if document exists
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Document not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Document ${isPublic ? 'made public' : 'made private'} successfully`,
|
||||
document
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating document public status:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to update document public status'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark document as accessibility checked
|
||||
*/
|
||||
export const markAccessibilityChecked = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { accessibilityChecked } = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid document ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and update document
|
||||
const document = await Document.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
accessibilityChecked: accessibilityChecked !== undefined ? accessibilityChecked : true,
|
||||
lastModified: new Date()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// Check if document exists
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Document not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Document marked as ${accessibilityChecked ? 'accessibility checked' : 'not accessibility checked'} successfully`,
|
||||
document
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error marking document as accessibility checked:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to mark document as accessibility checked'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ dotenv.config();
|
|||
import eventRoutes from './routes/events';
|
||||
import memberRoutes from './routes/members';
|
||||
import videoRoutes from './routes/videos';
|
||||
import documentRoutes from './routes/documents';
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
|
@ -67,6 +68,7 @@ app.get('/health', (req, res) => {
|
|||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/members', memberRoutes);
|
||||
app.use('/api/videos', videoRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ const createUploadDirectories = () => {
|
|||
'uploads/videos',
|
||||
'uploads/thumbnails',
|
||||
'uploads/subtitles',
|
||||
'uploads/transcripts'
|
||||
'uploads/transcripts',
|
||||
'uploads/documents',
|
||||
'uploads/textversions'
|
||||
];
|
||||
|
||||
dirs.forEach(dir => {
|
||||
|
|
@ -41,6 +43,10 @@ const storage = multer.diskStorage({
|
|||
uploadPath = path.join(uploadPath, 'subtitles');
|
||||
} else if (file.fieldname === 'transcript') {
|
||||
uploadPath = path.join(uploadPath, 'transcripts');
|
||||
} else if (file.fieldname === 'document') {
|
||||
uploadPath = path.join(uploadPath, 'documents');
|
||||
} else if (file.fieldname === 'textversion') {
|
||||
uploadPath = path.join(uploadPath, 'textversions');
|
||||
}
|
||||
|
||||
cb(null, uploadPath);
|
||||
|
|
@ -60,7 +66,9 @@ const fileFilter = (req: Request, file: MulterFile, cb: any) => {
|
|||
video: ['.mp4', '.mov', '.avi', '.webm'],
|
||||
thumbnail: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
||||
subtitles: ['.vtt', '.srt'],
|
||||
transcript: ['.txt', '.json']
|
||||
transcript: ['.txt', '.json'],
|
||||
document: ['.pdf', '.doc', '.docx', '.txt', '.rtf', '.odt', '.ppt', '.pptx'],
|
||||
textversion: ['.txt']
|
||||
};
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
|
@ -90,6 +98,12 @@ export const uploadVideo = upload.fields([
|
|||
{ name: 'transcript', maxCount: 1 }
|
||||
]);
|
||||
|
||||
// Export document upload middleware
|
||||
export const uploadDocument = upload.fields([
|
||||
{ name: 'document', maxCount: 1 },
|
||||
{ name: 'textversion', maxCount: 1 }
|
||||
]);
|
||||
|
||||
// Helper to get file paths from multer request
|
||||
export const getUploadedFilePaths = (req: Request) => {
|
||||
const files = req.files as { [fieldname: string]: MulterFile[] } | undefined;
|
||||
|
|
@ -98,6 +112,8 @@ export const getUploadedFilePaths = (req: Request) => {
|
|||
videoPath: files?.video?.[0]?.path,
|
||||
thumbnailPath: files?.thumbnail?.[0]?.path,
|
||||
subtitlesPath: files?.subtitles?.[0]?.path,
|
||||
transcriptPath: files?.transcript?.[0]?.path
|
||||
transcriptPath: files?.transcript?.[0]?.path,
|
||||
documentPath: files?.document?.[0]?.path,
|
||||
textVersionPath: files?.textversion?.[0]?.path
|
||||
};
|
||||
};
|
||||
|
|
|
|||
106
backend/src/models/Document.ts
Normal file
106
backend/src/models/Document.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
// Document document interface
|
||||
export interface IDocument extends Document {
|
||||
title: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
originalFilename: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' |
|
||||
'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' |
|
||||
'meeting_agenda' | 'board_meeting_agenda' | 'committee_meeting_agenda' | 'program_document';
|
||||
isPublic: boolean;
|
||||
meetingDate?: Date;
|
||||
uploadDate: Date;
|
||||
lastModified: Date;
|
||||
accessibilityChecked: boolean;
|
||||
hasTextVersion: boolean;
|
||||
textVersionPath?: string;
|
||||
}
|
||||
|
||||
// Document schema
|
||||
const DocumentSchema: Schema = new Schema({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
minLength: 3
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
originalFilename: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
fileType: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
fileSize: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
documentType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
'meeting_minutes', 'board_meeting_minutes', 'committee_meeting_minutes', 'annual_meeting_minutes',
|
||||
'bylaws', 'financial_report', 'annual_report', 'board_report', 'committee_report',
|
||||
'meeting_agenda', 'board_meeting_agenda', 'committee_meeting_agenda', 'program_document'
|
||||
],
|
||||
index: true
|
||||
},
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
index: true
|
||||
},
|
||||
meetingDate: {
|
||||
type: Date
|
||||
},
|
||||
uploadDate: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
lastModified: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
accessibilityChecked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasTextVersion: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
textVersionPath: {
|
||||
type: String,
|
||||
trim: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Create indexes for performance
|
||||
DocumentSchema.index({ title: 'text', description: 'text' });
|
||||
DocumentSchema.index({ documentType: 1 });
|
||||
DocumentSchema.index({ uploadDate: -1 });
|
||||
DocumentSchema.index({ isPublic: 1 });
|
||||
DocumentSchema.index({ meetingDate: 1 });
|
||||
|
||||
export default mongoose.model<IDocument>('Document', DocumentSchema);
|
||||
22
backend/src/routes/documents.ts
Normal file
22
backend/src/routes/documents.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import express from 'express';
|
||||
import { checkAuth } from '../middleware/auth';
|
||||
import * as documentController from '../controllers/documentController';
|
||||
import { uploadDocument } from '../middleware/upload';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Public routes
|
||||
router.get('/', documentController.getDocuments);
|
||||
router.get('/:id', documentController.getDocumentById);
|
||||
|
||||
// TEMPORARY: Authentication bypass for development
|
||||
// TODO: CRITICAL - Re-enable authentication middleware before production deployment
|
||||
// Protected routes (admin only)
|
||||
router.post('/', /* checkAuth, */ uploadDocument, documentController.createDocument); // TODO: Re-enable checkAuth
|
||||
router.put('/:id', /* checkAuth, */ documentController.updateDocument); // TODO: Re-enable checkAuth
|
||||
router.delete('/:id', /* checkAuth, */ documentController.deleteDocument); // TODO: Re-enable checkAuth
|
||||
router.post('/:id/textversion', /* checkAuth, */ uploadDocument, documentController.uploadTextVersion); // TODO: Re-enable checkAuth
|
||||
router.put('/:id/public', /* checkAuth, */ documentController.updatePublicStatus); // TODO: Re-enable checkAuth
|
||||
router.put('/:id/accessibility', /* checkAuth, */ documentController.markAccessibilityChecked); // TODO: Re-enable checkAuth
|
||||
|
||||
export default router;
|
||||
127
backend/src/services/documentProcessor.ts
Normal file
127
backend/src/services/documentProcessor.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
// Promisify exec
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* Document processing service
|
||||
*/
|
||||
export class DocumentProcessor {
|
||||
/**
|
||||
* Get file size in bytes
|
||||
* @param filePath Path to document file
|
||||
* @returns File size in bytes
|
||||
*/
|
||||
static async getFileSize(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
console.error('Error getting file size:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type from extension
|
||||
* @param filePath Path to document file
|
||||
* @returns File type (extension without dot)
|
||||
*/
|
||||
static getFileType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ext.startsWith('.') ? ext.substring(1) : ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text version of PDF document
|
||||
* @param pdfPath Path to PDF file
|
||||
* @param outputDir Path to output directory
|
||||
* @returns Path to generated text file
|
||||
*/
|
||||
static async generateTextVersion(
|
||||
pdfPath: string,
|
||||
outputDir: string
|
||||
): Promise<string> {
|
||||
console.log('Generating text version for document:', pdfPath);
|
||||
console.log('Output directory:', outputDir);
|
||||
|
||||
// Create unique filename
|
||||
const filename = `text-${Date.now()}.txt`;
|
||||
const outputPath = path.join(outputDir, filename);
|
||||
console.log('Output path:', outputPath);
|
||||
|
||||
// Check if document file exists
|
||||
try {
|
||||
const fileExists = await fs.pathExists(pdfPath);
|
||||
console.log('Document file exists:', fileExists);
|
||||
if (!fileExists) {
|
||||
throw new Error(`Document file does not exist: ${pdfPath}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Only process PDF files
|
||||
if (path.extname(pdfPath).toLowerCase() !== '.pdf') {
|
||||
throw new Error('Only PDF files can be converted to text');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use pdftotext if available (requires poppler-utils)
|
||||
await execPromise(`pdftotext "${pdfPath}" "${outputPath}"`);
|
||||
return outputPath;
|
||||
} catch (error) {
|
||||
console.error('Error generating text version:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/documents/document-1743031855065-584387717.pdf
Normal file
BIN
backend/uploads/documents/document-1743031855065-584387717.pdf
Normal file
Binary file not shown.
111
backend/uploads/textversions/text-1743031855070.txt
Normal file
111
backend/uploads/textversions/text-1743031855070.txt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
February 28, 2025
|
||||
Amy Adams, Program Coordinator
|
||||
Museum of Deaf History, Art, and Culture
|
||||
455 E Park St
|
||||
Olathe, KS 66061
|
||||
Dear Amy,
|
||||
When we stare into the night sky, each star tells a story - some burning bright with purpose, others
|
||||
finding new positions as their time in a particular formation evolves. Today, I must acknowledge that my
|
||||
star in the MDHAC constellation is shifting its orbit, no longer able to shine in the position it once held
|
||||
with such pride.
|
||||
The journey of a docent is like that of a sculptor, chiseling away at misconception until understanding
|
||||
emerges from the stone of ignorance. For years, I have carved pathways through the museum's halls,
|
||||
transforming everyday facts into transcendent experiences. Each tour became a living canvas where
|
||||
visitors could witness the vibrant brushstrokes of Deaf history painting itself before their eyes.
|
||||
My path with MDHAC officially began in 2018, during one of my darkest moments. After the election, I
|
||||
felt lost - dismissed - crushed. When someone from MDHAC invited me to sit at the front desk, I
|
||||
hesitated. Putting myself in public view again felt overwhelming, but it aligned with my passion for
|
||||
writing legal advocacy strategies on my laptop. I accepted, not realizing this small "yes" would
|
||||
transform my life's direction.
|
||||
Nearly a year later, when docent training opportunities arose through a partnership with Nelson-Atkins
|
||||
Museum of Art, my name wasn't on the candidate list. Yet somehow, as the person staffing the front
|
||||
desk, I was invited to participate as an unofficial candidate. I never intended to excel - in fact, I secretly
|
||||
hoped to perform poorly enough that I wouldn't be asked to lead tours. I worried the public wasn't ready
|
||||
for my perspectives on Deaf history and culture, which some considered too direct or intense.
|
||||
Then came that pivotal moment - giving a practice tour to volunteers from that same large museum.
|
||||
Something unexpected happened. These visitors resonated deeply with my authentic presentation of
|
||||
our Deaf history, perspectives, and visual way of experiencing the world. Their enthusiastic response,
|
||||
coupled with encouragement from the same person who first invited me to the front desk, convinced me
|
||||
to embrace this role I'd been avoiding.
|
||||
Now I stand at a crossroads. My work building Deaf leadership across the nation flows like a river that
|
||||
has outgrown its local banks and seeks broader horizons. This inner purpose draws me forward with
|
||||
the gravitational pull of countless stars, irresistible in its power.
|
||||
Meanwhile, the museum I once knew has transformed. Displays change without discussion, like
|
||||
paragraphs rewritten mid-story. The narrative I carefully constructed now contains segments I did not
|
||||
author, elements inserted without context, altering the story's very meaning. How can one tend a
|
||||
garden when the landscape itself shifts beneath one's hands?
|
||||
|
||||
Within these walls now exists a subtle current of dismissal - not violent like a storm, but persistent like
|
||||
erosion. My contributions, once treasured insights, now seem to disappear into seas of indifference. In
|
||||
spaces where my perspectives once flourished, I now perceive only fading impressions.
|
||||
The displays I meticulously incorporated into my tours have been altered without consultation, creating
|
||||
a disconnect between my prepared narrative and the visual story now presented. It feels like watching a
|
||||
familiar landscape suddenly rearrange itself, making my carefully mapped journey impossible to follow.
|
||||
My work on the proclamation for governors to recognize our community - words I selected to transform
|
||||
perceptions from medical pathology to cultural celebration - was altered without discussion before its
|
||||
presentation. The revised document, stripped of nuanced language highlighting our visual-spatial
|
||||
intelligence and unique contributions to humanity, felt like watching a vibrant painting fade to muted
|
||||
tones. When we reshape the public narrative about our existence but find our own words rewritten, we
|
||||
lose the authentic perspective that makes advocacy powerful.
|
||||
This pattern of disconnect feels especially poignant given the deep historical ties between KAD and this
|
||||
museum. In the 1980s, KAD spearheaded crucial fundraising drives that helped transform MDHAC
|
||||
from vision into reality. Even the LaRosh Fund contributed directly to this museum's construction. That
|
||||
foundation of mutual support makes the current distance all the more striking - like two branches that
|
||||
once grew from the same trunk now bending in different directions.
|
||||
More recently, my research about local Deaf history and the LaRosh Fund scholarship hasn't received
|
||||
the acknowledgment I believed it deserved. Offering these insights feels like extending a hand in
|
||||
friendship only to find it remains unclasped.
|
||||
I've also experienced a growing discomfort in the museum atmosphere. Where I once freely shared my
|
||||
cultural knowledge, I now find myself constrained by an invisible boundary of judgment. The joy of
|
||||
cultural exchange has been overshadowed by the weight of unseen scrutiny. What once felt like fertile
|
||||
ground for authentic expression now feels like shallow soil where genuine contributions struggle to take
|
||||
root.
|
||||
Deafhood demands authenticity. It requires spaces where our truths can breathe without constraint.
|
||||
When research meets skepticism, when history is treated as malleable rather than sacred, when lived
|
||||
experience is subordinated to administrative convenience - these create rifts that widen with time.
|
||||
I've been fortunate to witness different styles of leadership during my time at MDHAC. In the past, the
|
||||
museum radiated a welcoming energy that made volunteers feel appreciated for their contributions
|
||||
rather than merely fulfilling obligations. The atmosphere fostered creativity, mutual respect, and genuine
|
||||
community building. Volunteers eagerly returned, knowing their passion for Deaf culture was valued
|
||||
above all else. This collaborative spirit has dimmed in recent months, replaced by an environment that
|
||||
treats freely given time as a commodity and passion as a resource to be managed. The contrast
|
||||
between these approaches is evident in how volunteers now select their days of service, gravitating
|
||||
toward times when their contributions are celebrated rather than merely extracted.
|
||||
|
||||
The transformation in visitors' understanding when they grasp that being Deaf isn't about absence but
|
||||
presence - a vibrant way of experiencing the world through visual and spatial intelligence - has been
|
||||
the most rewarding aspect of my work.
|
||||
With this letter, I am formally withdrawing from my dedicated volunteer position as a docent tour guide
|
||||
at MDHAC. Like a meteor shower that transforms into individual shooting stars, each carrying wishes
|
||||
across the sky, my experiences here have scattered brilliant moments of connection that will illuminate
|
||||
paths I've yet to travel.
|
||||
I extend my deepest gratitude to everyone at MDHAC who provided the space for me to develop as a
|
||||
cultural ambassador. However, the current environment has become increasingly difficult for me to
|
||||
navigate. The dismissive treatment of my contributions, changes made to exhibits without consultation,
|
||||
and the uncomfortable atmosphere created by certain individuals have effectively pushed me away
|
||||
from the institution I once loved serving. These behaviors have transformed from occasional
|
||||
frustrations into a pattern that signals my full participation is no longer valued. While these experiences
|
||||
have shaped me profoundly, it's clear I must direct my energy where toxic interactions don't impede my
|
||||
ability to contribute meaningfully to our community's representation.
|
||||
My connection to our cultural heritage remains undiminished. While stepping back from scheduled
|
||||
docent responsibilities, I'll continue to visit the museum and participate in special events. Consider this
|
||||
not a departure but a transformation - like a butterfly emerging from a chrysalis, my dedication to our
|
||||
shared mission takes a new form while maintaining its essential nature.
|
||||
I still believe in MDHAC's potential to be a sanctuary where Deaf culture, art and history can flourish
|
||||
authentically. Perhaps when the museum establishes proper governance boundaries and accountability
|
||||
structures, when power is distributed rather than concentrated, and when volunteer contributions are
|
||||
welcomed rather than redirected, my regular involvement might resume. Until then, I must follow the
|
||||
path that allows my energy to flow most freely toward the advancement of our community in the most
|
||||
healthy form.
|
||||
Even as I shift my position, my commitment to preserving and celebrating our cultural legacy continues
|
||||
unabated. Like water finding a new channel, my dedication flows onward, nourishing different ground
|
||||
but still part of the same essential river.
|
||||
The stars in the night sky don't disappear when they move positions - they simply shine their light from
|
||||
a new place. That's what I'm doing now.
|
||||
With unwavering dedication to our shared journey,
|
||||
Chris Haulmark
|
||||
CC: Chriz Dally
|
||||
CC: Suz Dennis
|
||||
CC: Kim Anderson
|
||||
|
||||
|
||||
|
|
@ -66,15 +66,14 @@
|
|||
|
||||
## Next Steps
|
||||
1. Complete backend API integrations:
|
||||
- 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)
|
||||
- Connect admin UI components to backend endpoints (✓ Events management implemented, ✓ Members management implemented, ✓ Videos management implemented, ✓ Documents management implemented)
|
||||
- Add real data loading with loading states (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos, ✓ Implemented for Documents)
|
||||
- Implement error handling for API requests (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos, ✓ Implemented for Documents)
|
||||
- Set up client-side data validation (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos, ✓ Implemented for Documents)
|
||||
2. Implement remaining admin features:
|
||||
- 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
|
||||
- Add form handlers for CRUD operations (✓ Implemented for Events, ✓ Implemented for Members, ✓ Implemented for Videos, ✓ Implemented for Documents)
|
||||
- Create media upload components (✓ Implemented for Videos, ✓ Implemented for Documents)
|
||||
- Add user notification system (Next priority)
|
||||
3. Deploy staging environment:
|
||||
- Set up Docker containers for testing
|
||||
- Configure environment variables
|
||||
|
|
@ -85,6 +84,28 @@
|
|||
- All tools now use enhanced parameters (max_tokens: 4000, temperature: 0.7, top_p: 0.9)
|
||||
- Use for comprehensive documentation and API research
|
||||
|
||||
## Recent Document Management System Implementation
|
||||
- Created Document model with simplified schema for document metadata
|
||||
- Implemented DocumentController with CRUD operations and accessibility features
|
||||
- Added document processor service for file handling and text version generation
|
||||
- Updated upload middleware to handle document files and text versions
|
||||
- Created useDocuments hook for frontend data management
|
||||
- Simplified document model by removing category field and replacing status with isPublic flag
|
||||
- Enhanced document types to be more specific and user-friendly
|
||||
- Implemented document listing page with filtering by type and public/private status
|
||||
- Added document creation form with file upload and metadata fields
|
||||
- Created document details view page with improved PDF preview and download options
|
||||
- Fixed PDF preview to display in browser without forcing downloads
|
||||
- Implemented document editing functionality with pre-populated form
|
||||
- Added accessibility features tracking with text version support
|
||||
- Implemented automatic text version generation for PDF files
|
||||
- Added public/private toggle for simple visibility management
|
||||
- Created consistent UI patterns matching other admin interfaces
|
||||
- Ensured proper file type validation and error handling
|
||||
- Added delete confirmation dialog for document deletion
|
||||
- Implemented accessibility checked toggle for document accessibility status
|
||||
- Fixed uploads API route to properly handle PDF files and other document formats
|
||||
|
||||
## Recent Video Management System Enhancements
|
||||
- Implemented file upload system for videos, thumbnails, subtitles, and transcripts
|
||||
- Added automatic video duration detection using ffmpeg
|
||||
|
|
@ -113,6 +134,7 @@
|
|||
- Members routes (POST, PUT, DELETE, bulk-action)
|
||||
- Events routes (POST, PUT, DELETE, registration)
|
||||
- Videos routes (POST, PUT, DELETE, subtitles, transcript, thumbnail, publish)
|
||||
- Documents routes (POST, PUT, DELETE, textversion, public, accessibility)
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
- [x] Document upload and categorization
|
||||
- [x] Permission controls (public/private)
|
||||
- [x] Document search and filtering
|
||||
- [x] Simplified document model with intuitive public/private toggle
|
||||
- [x] Create content management system
|
||||
- [x] Page listing and organization
|
||||
- [x] WYSIWYG editor interface
|
||||
|
|
@ -88,8 +89,14 @@
|
|||
- [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
|
||||
- [x] Connect Documents management to backend API
|
||||
- [x] Implement real data loading with state management (Documents)
|
||||
- [x] Create API error handling and recovery (Documents)
|
||||
- [x] Develop form submissions with validation (Documents)
|
||||
- [x] Implement file upload and processing for Documents
|
||||
- [x] Build media upload functionality
|
||||
- [x] Simplify document management with improved PDF preview
|
||||
- [x] Fix API route for proper document handling
|
||||
- [ ] Implement user notification system
|
||||
- [ ] Create advanced filtering for data tables
|
||||
- [ ] Develop data export functionality
|
||||
|
|
@ -137,6 +144,7 @@
|
|||
- [ ] Members routes (POST, PUT, DELETE, bulk-action)
|
||||
- [ ] Events routes (POST, PUT, DELETE, registration)
|
||||
- [ ] Videos routes (POST, PUT, DELETE, subtitles, transcript, thumbnail, publish)
|
||||
- [ ] Documents routes (POST, PUT, DELETE, textversion, public, accessibility)
|
||||
- [ ] Implement proper JWT authentication system
|
||||
- [ ] Set up secure token storage and refresh mechanism
|
||||
- [ ] Configure proper CORS settings for production
|
||||
|
|
@ -155,9 +163,9 @@
|
|||
- [ ] Add comprehensive JSDoc comments
|
||||
|
||||
## Project Stats
|
||||
- **Completed Tasks:** 61
|
||||
- **In Progress Tasks:** 3
|
||||
- **Upcoming Tasks:** 23
|
||||
- **Completion Rate:** ~72%
|
||||
- **Completed Tasks:** 68
|
||||
- **In Progress Tasks:** 0
|
||||
- **Upcoming Tasks:** 21
|
||||
- **Completion Rate:** ~77%
|
||||
- **Current Phase:** Phase 4 - API Integration & Backend Functionality
|
||||
- **Next Major Milestone:** Complete Documents Management API integration
|
||||
- **Next Major Milestone:** Implement user notification system
|
||||
|
|
|
|||
158
frontend/src/app/admin/documents/[id]/edit/page.tsx
Normal file
158
frontend/src/app/admin/documents/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useDocuments, Document } from '../../../../../hooks/useDocuments';
|
||||
import DocumentForm from '../../../../../components/admin/DocumentForm';
|
||||
|
||||
export default function EditDocumentPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { getDocumentById, updateDocument } = useDocuments();
|
||||
|
||||
// Document state
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Get document ID from params
|
||||
const documentId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
|
||||
// Fetch document data
|
||||
const fetchDocument = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const doc = await getDocumentById(documentId);
|
||||
|
||||
if (doc) {
|
||||
setDocument(doc);
|
||||
} else {
|
||||
setError(new Error('Document not found'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch document'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
try {
|
||||
// If no document file is provided, we need to update the document data directly
|
||||
if (!formData.get('document')) {
|
||||
const updateData: Partial<Document> = {
|
||||
title: formData.get('title') as string,
|
||||
description: formData.get('description') as string,
|
||||
documentType: formData.get('documentType') as any, // Cast to any to avoid type issues
|
||||
isPublic: formData.get('isPublic') === 'true',
|
||||
accessibilityChecked: formData.get('accessibilityChecked') === 'true'
|
||||
};
|
||||
|
||||
// Add meeting date if provided
|
||||
if (formData.get('meetingDate')) {
|
||||
// Convert string date to Date object
|
||||
updateData.meetingDate = new Date(formData.get('meetingDate') as string);
|
||||
}
|
||||
|
||||
return await updateDocument(documentId, updateData);
|
||||
}
|
||||
|
||||
// Otherwise, submit the form data directly (FormData will be handled by the API)
|
||||
return await updateDocument(documentId, formData as any);
|
||||
} catch (err) {
|
||||
console.error('Error updating document:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Load document on initial render
|
||||
useEffect(() => {
|
||||
fetchDocument();
|
||||
}, [documentId]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-primary" 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md mb-6">
|
||||
<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 loading document
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Link href="/admin/documents" className="btn btn-primary">
|
||||
Back to Documents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Document not found
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">Document not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
The document you are looking for does not exist or has been deleted.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/admin/documents" className="btn btn-primary">
|
||||
Back to Documents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Edit Document</h1>
|
||||
<Link href={`/admin/documents/${documentId}`} className="btn btn-outline">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DocumentForm
|
||||
initialData={document}
|
||||
onSubmit={handleSubmit}
|
||||
isEdit={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
549
frontend/src/app/admin/documents/[id]/page.tsx
Normal file
549
frontend/src/app/admin/documents/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useDocuments, Document } from '../../../../hooks/useDocuments';
|
||||
import { formatDate, formatFileSize } from '../../../../utils/formatters';
|
||||
|
||||
export default function DocumentDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const {
|
||||
getDocumentById,
|
||||
deleteDocument,
|
||||
updatePublicStatus,
|
||||
markAccessibilityChecked,
|
||||
uploadTextVersion
|
||||
} = useDocuments();
|
||||
|
||||
// Document state
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// File upload state
|
||||
const [textVersionFile, setTextVersionFile] = useState<File | null>(null);
|
||||
const [uploadingTextVersion, setUploadingTextVersion] = useState(false);
|
||||
|
||||
// Get document ID from params
|
||||
const documentId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
|
||||
// Fetch document data
|
||||
const fetchDocument = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const doc = await getDocumentById(documentId);
|
||||
|
||||
if (doc) {
|
||||
setDocument(doc);
|
||||
} else {
|
||||
setError(new Error('Document not found'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch document'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation cancel
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
// Handle delete confirmation confirm
|
||||
const handleDeleteConfirm = async () => {
|
||||
try {
|
||||
const success = await deleteDocument(documentId);
|
||||
|
||||
if (success) {
|
||||
router.push('/admin/documents');
|
||||
} else {
|
||||
setError(new Error('Failed to delete document'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting document:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to delete document'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle public status toggle
|
||||
const handlePublicToggle = async () => {
|
||||
if (!document) return;
|
||||
|
||||
try {
|
||||
const updatedDoc = await updatePublicStatus(documentId, !document.isPublic);
|
||||
|
||||
if (updatedDoc) {
|
||||
setDocument(updatedDoc);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating public status:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to update public status'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle accessibility checked toggle
|
||||
const handleAccessibilityToggle = async () => {
|
||||
if (!document) return;
|
||||
|
||||
try {
|
||||
const updatedDoc = await markAccessibilityChecked(documentId, !document.accessibilityChecked);
|
||||
|
||||
if (updatedDoc) {
|
||||
setDocument(updatedDoc);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating accessibility status:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to update accessibility status'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle text version file change
|
||||
const handleTextVersionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setTextVersionFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle text version upload
|
||||
const handleTextVersionUpload = async () => {
|
||||
if (!textVersionFile) return;
|
||||
|
||||
setUploadingTextVersion(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('textversion', textVersionFile);
|
||||
|
||||
const updatedDoc = await uploadTextVersion(documentId, formData);
|
||||
|
||||
if (updatedDoc) {
|
||||
setDocument(updatedDoc);
|
||||
setTextVersionFile(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error uploading text version:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to upload text version'));
|
||||
} finally {
|
||||
setUploadingTextVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get document type display name
|
||||
const getDocumentTypeDisplayName = (type: string): string => {
|
||||
return type
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Load document on initial render
|
||||
useEffect(() => {
|
||||
fetchDocument();
|
||||
}, [documentId]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-primary" 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md mb-6">
|
||||
<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 loading document
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Link href="/admin/documents" className="btn btn-primary">
|
||||
Back to Documents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Document not found
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">Document not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
The document you are looking for does not exist or has been deleted.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/admin/documents" className="btn btn-primary">
|
||||
Back to Documents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{document.title}</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Uploaded on {formatDate(document.uploadDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2 mt-4 md:mt-0">
|
||||
<Link
|
||||
href={`/admin/documents/${document._id}/edit`}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Main content */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Document preview */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Document Preview</h2>
|
||||
{document.fileType === 'pdf' ? (
|
||||
<div className="h-96 bg-gray-200 dark:bg-gray-800 rounded-md overflow-hidden">
|
||||
<object
|
||||
data={`/api/uploads/${document.filePath}`}
|
||||
type="application/pdf"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<p className="p-4 text-center">
|
||||
Your browser does not support PDF preview.
|
||||
<a
|
||||
href={`/api/uploads/${document.filePath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-2 text-primary hover:text-primary-dark"
|
||||
>
|
||||
Click here to open the PDF
|
||||
</a>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-200 dark:bg-gray-800 rounded-md">
|
||||
<div className="text-center">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Preview not available for {document.fileType.toUpperCase()} files
|
||||
</p>
|
||||
<a
|
||||
href={`/api/uploads/${document.filePath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Download to View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={`/api/uploads/${document.filePath}`}
|
||||
download={document.originalFilename}
|
||||
className="inline-flex items-center text-primary hover:text-primary-dark"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Document
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Description</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||
{document.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Accessibility */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Accessibility</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-medium">Accessibility Status</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Mark this document as checked for accessibility
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAccessibilityToggle}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
document.accessibilityChecked
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{document.accessibilityChecked ? 'Checked' : 'Not Checked'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-medium">Text Version</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{document.hasTextVersion
|
||||
? 'This document has a text version available'
|
||||
: 'No text version available for this document'}
|
||||
</p>
|
||||
</div>
|
||||
{document.hasTextVersion ? (
|
||||
<a
|
||||
href={`/api/uploads/${document.textVersionPath}`}
|
||||
download
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
Download Text Version
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="file"
|
||||
id="textVersion"
|
||||
accept=".txt"
|
||||
onChange={handleTextVersionChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="textVersion"
|
||||
className="btn btn-secondary btn-sm cursor-pointer"
|
||||
>
|
||||
Upload Text Version
|
||||
</label>
|
||||
{textVersionFile && (
|
||||
<button
|
||||
onClick={handleTextVersionUpload}
|
||||
disabled={uploadingTextVersion}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{uploadingTextVersion ? 'Uploading...' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Document info */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Document Information</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Type</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{getDocumentTypeDisplayName(document.documentType)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{document.meetingDate && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Meeting Date</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatDate(document.meetingDate)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">File Type</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{document.fileType.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">File Size</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatFileSize(document.fileSize)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Original Filename</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100 break-all">
|
||||
{document.originalFilename}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Upload Date</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatDate(document.uploadDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Last Modified</h3>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatDate(document.lastModified)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Visibility</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-medium">Public Status</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{document.isPublic
|
||||
? 'This document is visible to all users'
|
||||
: 'This document is only visible to administrators'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePublicToggle}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
document.isPublic
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{document.isPublic ? 'Public' : 'Private'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Actions</h2>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={`/admin/documents/${document._id}/edit`}
|
||||
className="btn btn-secondary w-full"
|
||||
>
|
||||
Edit Document
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="btn btn-danger w-full"
|
||||
>
|
||||
Delete Document
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/documents"
|
||||
className="btn btn-outline w-full"
|
||||
>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
|
||||
</div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div className="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
|
||||
Delete Document
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to delete this document? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteConfirm}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteCancel}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/app/admin/documents/create/page.tsx
Normal file
44
frontend/src/app/admin/documents/create/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useDocuments } from '../../../../hooks/useDocuments';
|
||||
import DocumentForm from '../../../../components/admin/DocumentForm';
|
||||
|
||||
export default function CreateDocumentPage() {
|
||||
const router = useRouter();
|
||||
const { createDocument } = useDocuments();
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
try {
|
||||
const result = await createDocument(formData);
|
||||
|
||||
if (result) {
|
||||
router.push(`/admin/documents/${result._id}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error creating document:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Create New Document</h1>
|
||||
<Link href="/admin/documents" className="btn btn-outline">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DocumentForm
|
||||
onSubmit={handleSubmit}
|
||||
isEdit={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,474 +1,424 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Document Filter Component
|
||||
const DocumentFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { category: string; isPublic: string; search: string }) => void
|
||||
}) => {
|
||||
const [category, setCategory] = useState('all');
|
||||
const [isPublic, setIsPublic] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ category, isPublic, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setCategory('all');
|
||||
setIsPublic('all');
|
||||
setSearch('');
|
||||
onFilterChange({ category: 'all', isPublic: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Documents</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
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={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
<option value="minutes">Meeting Minutes</option>
|
||||
<option value="bylaws">Bylaws</option>
|
||||
<option value="forms">Forms</option>
|
||||
<option value="reports">Reports</option>
|
||||
<option value="newsletters">Newsletters</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="isPublic" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="isPublic"
|
||||
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={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.value)}
|
||||
>
|
||||
<option value="all">All Documents</option>
|
||||
<option value="public">Public Only</option>
|
||||
<option value="private">Private Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
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="Search by title or description"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDocuments, Document } from '../../../hooks/useDocuments';
|
||||
import { formatDate, formatFileSize } from '../../../utils/formatters';
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [filter, setFilter] = useState({ category: 'all', isPublic: 'all', search: '' });
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const router = useRouter();
|
||||
const {
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchDocuments,
|
||||
deleteDocument,
|
||||
updatePublicStatus,
|
||||
markAccessibilityChecked
|
||||
} = useDocuments();
|
||||
|
||||
// Mock documents data - would come from API in a real implementation
|
||||
const allDocuments = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Board Meeting Minutes - March 2025',
|
||||
description: 'Minutes from the March 10, 2025 board meeting',
|
||||
category: 'minutes',
|
||||
fileUrl: '/documents/minutes-march-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '284 KB',
|
||||
uploadDate: '2025-03-15',
|
||||
lastModified: '2025-03-15',
|
||||
isPublic: false,
|
||||
tags: ['board', 'minutes', 'march', '2025']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'OCD Bylaws 2025',
|
||||
description: 'Updated bylaws for the Olathe Club of the Deaf',
|
||||
category: 'bylaws',
|
||||
fileUrl: '/documents/ocd-bylaws-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '567 KB',
|
||||
uploadDate: '2025-01-05',
|
||||
lastModified: '2025-01-05',
|
||||
isPublic: true,
|
||||
tags: ['bylaws', 'rules', 'organization']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Membership Application Form',
|
||||
description: 'Form for new membership applications',
|
||||
category: 'forms',
|
||||
fileUrl: '/documents/membership-form.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '125 KB',
|
||||
uploadDate: '2024-12-10',
|
||||
lastModified: '2025-01-15',
|
||||
isPublic: true,
|
||||
tags: ['membership', 'application', 'form']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Annual Financial Report 2024',
|
||||
description: 'Financial report for the fiscal year 2024',
|
||||
category: 'reports',
|
||||
fileUrl: '/documents/financial-report-2024.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '1.2 MB',
|
||||
uploadDate: '2025-02-15',
|
||||
lastModified: '2025-02-15',
|
||||
isPublic: false,
|
||||
tags: ['financial', 'annual', 'report', '2024']
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Winter Newsletter 2025',
|
||||
description: 'Newsletter for winter 2025 with club updates and events',
|
||||
category: 'newsletters',
|
||||
fileUrl: '/documents/newsletter-winter-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '3.4 MB',
|
||||
uploadDate: '2025-01-10',
|
||||
lastModified: '2025-01-10',
|
||||
isPublic: true,
|
||||
tags: ['newsletter', 'winter', '2025', 'events']
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Event Proposal Form',
|
||||
description: 'Form for members to propose new events',
|
||||
category: 'forms',
|
||||
fileUrl: '/documents/event-proposal-form.docx',
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
fileSize: '68 KB',
|
||||
uploadDate: '2024-11-20',
|
||||
lastModified: '2024-11-20',
|
||||
isPublic: true,
|
||||
tags: ['events', 'proposal', 'form']
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Board Meeting Minutes - February 2025',
|
||||
description: 'Minutes from the February 12, 2025 board meeting',
|
||||
category: 'minutes',
|
||||
fileUrl: '/documents/minutes-february-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '312 KB',
|
||||
uploadDate: '2025-02-18',
|
||||
lastModified: '2025-02-18',
|
||||
isPublic: false,
|
||||
tags: ['board', 'minutes', 'february', '2025']
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Member Handbook',
|
||||
description: 'Comprehensive handbook for all OCD members',
|
||||
category: 'bylaws',
|
||||
fileUrl: '/documents/member-handbook.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '4.2 MB',
|
||||
uploadDate: '2025-01-05',
|
||||
lastModified: '2025-01-05',
|
||||
isPublic: true,
|
||||
tags: ['handbook', 'members', 'rules', 'guidelines']
|
||||
},
|
||||
];
|
||||
|
||||
// Filter documents based on current filter settings
|
||||
const filteredDocuments = allDocuments.filter(document => {
|
||||
if (filter.category !== 'all' && document.category !== filter.category) return false;
|
||||
|
||||
if (filter.isPublic !== 'all') {
|
||||
const isPublicBool = filter.isPublic === 'public';
|
||||
if (document.isPublic !== isPublicBool) return false;
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
if (!document.title.toLowerCase().includes(searchLower) &&
|
||||
!document.description.toLowerCase().includes(searchLower) &&
|
||||
!document.tags.some(tag => tag.toLowerCase().includes(searchLower))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
// Filter state
|
||||
const [filters, setFilters] = useState({
|
||||
documentType: 'all',
|
||||
search: '',
|
||||
isPublic: undefined as boolean | undefined,
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { category: string; isPublic: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAction = (action: string) => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Perform ${action} on ${selectedDocuments.length} documents`);
|
||||
};
|
||||
|
||||
// Handle select all checkbox
|
||||
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = event.target.checked;
|
||||
setSelectAll(checked);
|
||||
|
||||
// Selected documents for bulk actions
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null);
|
||||
|
||||
// Apply filters
|
||||
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
} else {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
const applyFilters = () => {
|
||||
// Reset page to 1 when applying new filters
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
page: 1
|
||||
}));
|
||||
|
||||
fetchDocuments({
|
||||
...filters,
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
documentType: 'all',
|
||||
search: '',
|
||||
isPublic: undefined,
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
fetchDocuments({
|
||||
documentType: 'all',
|
||||
search: '',
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || (pagination && newPage > pagination.pages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
page: newPage
|
||||
}));
|
||||
|
||||
fetchDocuments({
|
||||
...filters,
|
||||
page: newPage
|
||||
});
|
||||
};
|
||||
|
||||
// Handle document selection
|
||||
const handleSelectDocument = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedDocuments(filteredDocuments.map(doc => doc.id));
|
||||
setSelectedDocuments(prev => [...prev, id]);
|
||||
} else {
|
||||
setSelectedDocuments(prev => prev.filter(docId => docId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle select all documents
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedDocuments(documents.map(doc => doc._id));
|
||||
} else {
|
||||
setSelectedDocuments([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle individual checkbox selection
|
||||
const handleSelectDocument = (documentId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedDocuments(prev => [...prev, documentId]);
|
||||
} else {
|
||||
setSelectedDocuments(prev => prev.filter(id => id !== documentId));
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDocumentToDelete(id);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation cancel
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDocumentToDelete(null);
|
||||
};
|
||||
|
||||
// Handle delete confirmation confirm
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (documentToDelete) {
|
||||
const success = await deleteDocument(documentToDelete);
|
||||
|
||||
if (success) {
|
||||
// Remove from selected documents if it was selected
|
||||
setSelectedDocuments(prev => prev.filter(id => id !== documentToDelete));
|
||||
}
|
||||
|
||||
setShowDeleteConfirm(false);
|
||||
setDocumentToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Handle public status toggle
|
||||
const handlePublicToggle = async (id: string, isPublic: boolean) => {
|
||||
await updatePublicStatus(id, !isPublic);
|
||||
};
|
||||
|
||||
// Get file icon based on MIME type
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.includes('pdf')) {
|
||||
return (
|
||||
<svg className="w-5 h-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 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType.includes('word') || mimeType.includes('document')) {
|
||||
return (
|
||||
<svg className="w-5 h-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) {
|
||||
return (
|
||||
<svg className="w-5 h-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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<svg className="w-5 h-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle accessibility checked toggle
|
||||
const handleAccessibilityToggle = async (id: string, checked: boolean) => {
|
||||
await markAccessibilityChecked(id, !checked);
|
||||
};
|
||||
|
||||
|
||||
// Get document type display name
|
||||
const getDocumentTypeDisplayName = (type: string): string => {
|
||||
return type
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Load documents on initial render
|
||||
useEffect(() => {
|
||||
fetchDocuments(filters);
|
||||
}, [fetchDocuments]);
|
||||
|
||||
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">Documents</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage bylaws, meeting minutes, forms, and other documents.
|
||||
</p>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Documents</h1>
|
||||
<Link href="/admin/documents/create" className="btn btn-primary">
|
||||
Add New Document
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filters</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="documentType" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Document Type
|
||||
</label>
|
||||
<select
|
||||
id="documentType"
|
||||
name="documentType"
|
||||
value={filters.documentType}
|
||||
onChange={handleFilterChange}
|
||||
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="all">All Types</option>
|
||||
<option value="meeting_minutes">Meeting Minutes</option>
|
||||
<option value="board_meeting_minutes">Board Meeting Minutes</option>
|
||||
<option value="committee_meeting_minutes">Committee Meeting Minutes</option>
|
||||
<option value="annual_meeting_minutes">Annual Meeting Minutes</option>
|
||||
<option value="bylaws">Bylaws</option>
|
||||
<option value="financial_report">Financial Report</option>
|
||||
<option value="annual_report">Annual Report</option>
|
||||
<option value="board_report">Board Report</option>
|
||||
<option value="committee_report">Committee Report</option>
|
||||
<option value="meeting_agenda">Meeting Agenda</option>
|
||||
<option value="board_meeting_agenda">Board Meeting Agenda</option>
|
||||
<option value="committee_meeting_agenda">Committee Meeting Agenda</option>
|
||||
<option value="program_document">Program Document</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="isPublic" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="isPublic"
|
||||
name="isPublic"
|
||||
value={filters.isPublic === undefined ? '' : filters.isPublic ? 'true' : 'false'}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
isPublic: value === '' ? undefined : value === 'true'
|
||||
}));
|
||||
}}
|
||||
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="">All</option>
|
||||
<option value="true">Public</option>
|
||||
<option value="false">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
value={filters.search}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Search by title or description"
|
||||
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>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/documents/upload"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
|
||||
<div className="flex justify-end mt-4 space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
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-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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Upload Document
|
||||
</Link>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyFilters}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter component */}
|
||||
<DocumentFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedDocuments.length > 0 && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-md mb-6 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-200">
|
||||
{selectedDocuments.length} document{selectedDocuments.length > 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkAction('download')}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 dark:text-blue-100 dark:bg-blue-800 dark:hover:bg-blue-700"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-md mb-6">
|
||||
<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 loading documents
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-primary" 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
{!loading && documents.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="select-all"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAll}
|
||||
checked={selectedDocuments.length === documents.length && documents.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="select-all" className="sr-only">Select All</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Document
|
||||
Title
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Dates
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Visibility
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Accessibility
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredDocuments.map((document) => (
|
||||
<tr key={document.id}>
|
||||
{documents.map((document) => (
|
||||
<tr key={document._id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={`select-${document.id}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectedDocuments.includes(document.id)}
|
||||
onChange={(e) => handleSelectDocument(document.id, e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`select-${document.id}`} className="sr-only">Select {document.title}</label>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectedDocuments.includes(document._id)}
|
||||
onChange={(e) => handleSelectDocument(document._id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 text-gray-500 dark:text-gray-400 mr-3">
|
||||
{getFileIcon(document.mimeType)}
|
||||
<div className="flex-shrink-0 h-10 w-10 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-md">
|
||||
<svg className="h-6 w-6 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{document.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{document.description}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{document.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{document.fileType.toUpperCase()} • {formatFileSize(document.fileSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white capitalize">{document.category}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{document.fileSize}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Uploaded: {formatDate(document.uploadDate)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Modified: {formatDate(document.lastModified)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${document.isPublic ? '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'}`}>
|
||||
{document.isPublic ? 'Public' : 'Private'}
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
|
||||
{getDocumentTypeDisplayName(document.documentType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<a
|
||||
href={document.fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handlePublicToggle(document._id, document.isPublic)}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
document.isPublic
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{document.isPublic ? 'Public' : 'Private'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleAccessibilityToggle(document._id, document.accessibilityChecked)}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
document.accessibilityChecked
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{document.accessibilityChecked ? 'Checked' : 'Unchecked'}
|
||||
</button>
|
||||
{document.hasTextVersion && (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200">
|
||||
Text Version
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(document.uploadDate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/admin/documents/${document._id}`}
|
||||
className="text-primary hover:text-primary-dark"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
<Link href={`/admin/documents/${document.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/documents/${document._id}/edit`}
|
||||
className="text-yellow-600 hover:text-yellow-900 dark:text-yellow-500 dark:hover:text-yellow-400"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Delete document: ${document.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
<button
|
||||
onClick={() => handleDeleteClick(document._id)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-500 dark:hover:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
|
@ -479,13 +429,124 @@ export default function DocumentsPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredDocuments.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No documents found matching the current filters.</p>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && documents.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No documents found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Get started by creating a new document.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/admin/documents/create" className="btn btn-primary">
|
||||
Add New Document
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.pages > 1 && (
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing <span className="font-medium">{(pagination.page - 1) * pagination.limit + 1}</span> to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium">{pagination.total}</span> results
|
||||
</div>
|
||||
<nav className="flex space-x-2" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className={`px-3 py-1 rounded-md ${
|
||||
pagination.page === 1
|
||||
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: pagination.pages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`px-3 py-1 rounded-md ${
|
||||
pagination.page === page
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.pages}
|
||||
className={`px-3 py-1 rounded-md ${
|
||||
pagination.page === pagination.pages
|
||||
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
|
||||
</div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div className="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
|
||||
Delete Document
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to delete this document? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteConfirm}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteCancel}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ export async function GET(
|
|||
) {
|
||||
try {
|
||||
// Get the file path from the URL
|
||||
const pathParams = await Promise.resolve(params.path);
|
||||
const filePath = pathParams.join('/');
|
||||
const filePath = params.path.join('/');
|
||||
|
||||
// Construct the absolute path to the file
|
||||
// This assumes the backend is in the same directory as the frontend
|
||||
|
|
@ -64,6 +63,17 @@ export async function GET(
|
|||
case '.txt':
|
||||
contentType = 'text/plain';
|
||||
break;
|
||||
case '.pdf':
|
||||
contentType = 'application/pdf';
|
||||
break;
|
||||
case '.doc':
|
||||
case '.docx':
|
||||
contentType = 'application/msword';
|
||||
break;
|
||||
case '.ppt':
|
||||
case '.pptx':
|
||||
contentType = 'application/vnd.ms-powerpoint';
|
||||
break;
|
||||
}
|
||||
|
||||
// Return the file with appropriate headers
|
||||
|
|
|
|||
560
frontend/src/components/admin/DocumentForm.tsx
Normal file
560
frontend/src/components/admin/DocumentForm.tsx
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Document } from '../../hooks/useDocuments';
|
||||
|
||||
interface DocumentFormProps {
|
||||
initialData?: Partial<Document>;
|
||||
onSubmit: (data: FormData) => Promise<Document | null>;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const DocumentForm: React.FC<DocumentFormProps> = ({
|
||||
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 documentInputRef = useRef<HTMLInputElement>(null);
|
||||
const textVersionInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
documentType: 'meeting_minutes',
|
||||
isPublic: false,
|
||||
meetingDate: '',
|
||||
accessibilityChecked: false,
|
||||
hasTextVersion: false
|
||||
});
|
||||
|
||||
// File state
|
||||
const [documentFile, setDocumentFile] = useState<File | null>(null);
|
||||
const [textVersionFile, setTextVersionFile] = useState<File | null>(null);
|
||||
|
||||
// Preview state
|
||||
const [documentPreview, setDocumentPreview] = useState<string | null>(null);
|
||||
const [documentName, setDocumentName] = useState<string>('');
|
||||
const [documentSize, setDocumentSize] = useState<number>(0);
|
||||
const [documentType, setDocumentType] = 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 || '',
|
||||
documentType: initialData.documentType || 'meeting_minutes',
|
||||
isPublic: initialData.isPublic || false,
|
||||
meetingDate: initialData.meetingDate ? new Date(initialData.meetingDate).toISOString().split('T')[0] : '',
|
||||
accessibilityChecked: initialData.accessibilityChecked || false,
|
||||
hasTextVersion: initialData.hasTextVersion || false
|
||||
});
|
||||
|
||||
// Set document size if available
|
||||
if (initialData.fileSize) {
|
||||
setDocumentSize(initialData.fileSize);
|
||||
}
|
||||
|
||||
// Set document name if available
|
||||
if (initialData.originalFilename) {
|
||||
setDocumentName(initialData.originalFilename);
|
||||
}
|
||||
|
||||
// Set document type if available
|
||||
if (initialData.fileType) {
|
||||
setDocumentType(initialData.fileType);
|
||||
}
|
||||
|
||||
// Set preview URLs if available
|
||||
if (initialData.filePath) {
|
||||
setDocumentPreview(`/api/uploads/${initialData.filePath}`);
|
||||
}
|
||||
}
|
||||
}, [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 'document':
|
||||
setDocumentFile(file);
|
||||
setDocumentName(file.name);
|
||||
setDocumentSize(file.size);
|
||||
setDocumentType(file.name.split('.').pop()?.toLowerCase() || '');
|
||||
|
||||
// Create object URL for preview
|
||||
const documentUrl = URL.createObjectURL(file);
|
||||
setDocumentPreview(documentUrl);
|
||||
|
||||
// Clear error
|
||||
if (formErrors.document) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
document: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'textVersion':
|
||||
setTextVersionFile(file);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
hasTextVersion: true
|
||||
}));
|
||||
|
||||
// Clear error
|
||||
if (formErrors.textVersion) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
textVersion: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 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 (!documentFile && !initialData.filePath) {
|
||||
errors.document = 'Document file is required';
|
||||
}
|
||||
|
||||
if (!formData.documentType) {
|
||||
errors.documentType = 'Document type is required';
|
||||
}
|
||||
|
||||
// Validate meeting date if document type includes "minutes"
|
||||
if (formData.documentType.includes('minutes') && !formData.meetingDate) {
|
||||
errors.meetingDate = 'Meeting date is required for minutes';
|
||||
}
|
||||
|
||||
// Validate file types
|
||||
if (documentFile) {
|
||||
const allowedTypes = [
|
||||
'pdf', 'doc', 'docx', 'txt', 'rtf', 'odt', 'ppt', 'pptx'
|
||||
];
|
||||
const fileExt = documentFile.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (!allowedTypes.includes(fileExt)) {
|
||||
errors.document = 'Please upload a valid document file (PDF, DOC, DOCX, TXT, RTF, ODT, PPT, PPTX)';
|
||||
}
|
||||
}
|
||||
|
||||
if (textVersionFile && textVersionFile.type !== 'text/plain' && !textVersionFile.name.endsWith('.txt')) {
|
||||
errors.textVersion = 'Please upload a valid text file (.txt)';
|
||||
}
|
||||
|
||||
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('documentType', formData.documentType);
|
||||
formDataObj.append('isPublic', formData.isPublic.toString());
|
||||
|
||||
if (formData.meetingDate) {
|
||||
formDataObj.append('meetingDate', formData.meetingDate);
|
||||
}
|
||||
|
||||
formDataObj.append('accessibilityChecked', formData.accessibilityChecked.toString());
|
||||
|
||||
// Add files if they exist
|
||||
if (documentFile) {
|
||||
formDataObj.append('document', documentFile);
|
||||
}
|
||||
|
||||
if (textVersionFile) {
|
||||
formDataObj.append('textversion', textVersionFile);
|
||||
}
|
||||
|
||||
// Submit form data
|
||||
const result = await onSubmit(formDataObj);
|
||||
|
||||
if (result) {
|
||||
// Redirect to document details page or documents list
|
||||
if (isEdit) {
|
||||
router.push(`/admin/documents/${result._id}`);
|
||||
} else {
|
||||
router.push('/admin/documents');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle error
|
||||
setError(err.message || 'Failed to save document. Please try again.');
|
||||
console.error('Error saving document:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get document type display name
|
||||
const getDocumentTypeDisplayName = (type: string): string => {
|
||||
return type
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
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="Document 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="Document description"
|
||||
/>
|
||||
{formErrors.description && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="document" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Document File *
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="document"
|
||||
name="document"
|
||||
ref={documentInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".pdf,.doc,.docx,.txt,.rtf,.odt,.ppt,.pptx"
|
||||
className={`file-input ${formErrors.document ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.document && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.document}</p>
|
||||
)}
|
||||
|
||||
{/* Document info */}
|
||||
{(documentPreview || documentName) && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{documentName && <span className="font-medium">File: </span>}{documentName}
|
||||
</p>
|
||||
{documentSize > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">Size: </span>{formatFileSize(documentSize)}
|
||||
</p>
|
||||
)}
|
||||
{documentType && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">Type: </span>{documentType.toUpperCase()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document preview (for PDFs) */}
|
||||
{documentPreview && documentType === 'pdf' && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Document Preview</h4>
|
||||
<div className="h-64 bg-gray-200 dark:bg-gray-800 rounded-md overflow-hidden">
|
||||
<iframe
|
||||
src={documentPreview}
|
||||
className="w-full h-full"
|
||||
title="Document preview"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="documentType" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Document Type *
|
||||
</label>
|
||||
<select
|
||||
id="documentType"
|
||||
name="documentType"
|
||||
value={formData.documentType}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.documentType ? '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="meeting_minutes">Meeting Minutes</option>
|
||||
<option value="board_meeting_minutes">Board Meeting Minutes</option>
|
||||
<option value="committee_meeting_minutes">Committee Meeting Minutes</option>
|
||||
<option value="annual_meeting_minutes">Annual Meeting Minutes</option>
|
||||
<option value="bylaws">Bylaws</option>
|
||||
<option value="financial_report">Financial Report</option>
|
||||
<option value="annual_report">Annual Report</option>
|
||||
<option value="board_report">Board Report</option>
|
||||
<option value="committee_report">Committee Report</option>
|
||||
<option value="meeting_agenda">Meeting Agenda</option>
|
||||
<option value="board_meeting_agenda">Board Meeting Agenda</option>
|
||||
<option value="committee_meeting_agenda">Committee Meeting Agenda</option>
|
||||
<option value="program_document">Program Document</option>
|
||||
</select>
|
||||
{formErrors.documentType && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.documentType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.documentType.includes('minutes') && (
|
||||
<div>
|
||||
<label htmlFor="meetingDate" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Meeting Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="meetingDate"
|
||||
name="meetingDate"
|
||||
value={formData.meetingDate}
|
||||
onChange={handleChange}
|
||||
className={`block w-full px-3 py-2 border ${formErrors.meetingDate ? '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.meetingDate && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.meetingDate}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPublic"
|
||||
name="isPublic"
|
||||
checked={formData.isPublic}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="isPublic" className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Public (visible to all users)
|
||||
</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="textVersion" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Text Version File (TXT) <span className="text-xs text-gray-500">(Will be auto-generated for PDFs if not provided)</span>
|
||||
</label>
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="textVersion"
|
||||
name="textVersion"
|
||||
ref={textVersionInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".txt,text/plain"
|
||||
className={`file-input ${formErrors.textVersion ? 'border-red-300 dark:border-red-700' : 'border-gray-300 dark:border-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{formErrors.textVersion && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{formErrors.textVersion}</p>
|
||||
)}
|
||||
{textVersionFile && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">File: </span>{textVersionFile.name}
|
||||
</p>
|
||||
)}
|
||||
{initialData.textVersionPath && !textVersionFile && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">Current text version file: </span>
|
||||
{initialData.textVersionPath.split('/').pop()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="accessibilityChecked"
|
||||
name="accessibilityChecked"
|
||||
checked={formData.accessibilityChecked}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="accessibilityChecked" className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Mark as accessibility checked
|
||||
</label>
|
||||
</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.accessibilityChecked ?
|
||||
'This document will be marked as accessibility checked' :
|
||||
'This document will not be marked as accessibility checked'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-blue-600 dark:text-blue-300">
|
||||
Documents are automatically marked as having a text version when a text file is provided or generated.
|
||||
</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 DocumentForm;
|
||||
300
frontend/src/hooks/useDocuments.ts
Normal file
300
frontend/src/hooks/useDocuments.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Define Document interface
|
||||
export interface Document {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
originalFilename: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' |
|
||||
'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' |
|
||||
'meeting_agenda' | 'board_meeting_agenda' | 'committee_meeting_agenda' | 'program_document';
|
||||
isPublic: boolean;
|
||||
meetingDate?: Date;
|
||||
uploadDate: Date;
|
||||
lastModified: Date;
|
||||
accessibilityChecked: boolean;
|
||||
hasTextVersion: boolean;
|
||||
textVersionPath?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Define filter interface
|
||||
export interface DocumentFilter {
|
||||
documentType?: string;
|
||||
search?: string;
|
||||
isPublic?: 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 UseDocumentsReturn {
|
||||
documents: Document[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
pagination: Pagination | null;
|
||||
fetchDocuments: (filters?: DocumentFilter) => Promise<void>;
|
||||
getDocumentById: (id: string) => Promise<Document | null>;
|
||||
createDocument: (formData: FormData) => Promise<Document | null>;
|
||||
updateDocument: (id: string, documentData: Partial<Document>) => Promise<Document | null>;
|
||||
deleteDocument: (id: string) => Promise<boolean>;
|
||||
uploadTextVersion: (id: string, formData: FormData) => Promise<Document | null>;
|
||||
updatePublicStatus: (id: string, isPublic: boolean) => Promise<Document | null>;
|
||||
markAccessibilityChecked: (id: string, checked: boolean) => Promise<Document | null>;
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
/**
|
||||
* Hook for managing documents data
|
||||
*/
|
||||
export function useDocuments(): UseDocumentsReturn {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch documents with optional filtering
|
||||
*/
|
||||
const fetchDocuments = useCallback(async (filters?: DocumentFilter) => {
|
||||
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}/documents?${queryParams.toString()}`);
|
||||
setDocuments(response.data.documents);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Error fetching documents:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch documents'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get a single document by ID
|
||||
*/
|
||||
const getDocumentById = useCallback(async (id: string): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/documents/${id}`);
|
||||
return response.data.document;
|
||||
} catch (err) {
|
||||
console.error(`Error fetching document with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new document
|
||||
*/
|
||||
const createDocument = useCallback(async (formData: FormData): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/documents`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh documents list after creating
|
||||
fetchDocuments();
|
||||
|
||||
return response.data.document;
|
||||
} catch (err) {
|
||||
console.error('Error creating document:', err);
|
||||
// Rethrow the error so it can be caught and handled by the component
|
||||
throw err;
|
||||
}
|
||||
}, [fetchDocuments]);
|
||||
|
||||
/**
|
||||
* Update an existing document
|
||||
*/
|
||||
const updateDocument = useCallback(async (id: string, documentData: Partial<Document>): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/documents/${id}`, documentData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setDocuments(prevDocuments =>
|
||||
prevDocuments.map(document =>
|
||||
document._id === id ? { ...document, ...response.data.document } : document
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.document;
|
||||
} catch (err) {
|
||||
console.error(`Error updating document with ID ${id}:`, err);
|
||||
// Rethrow the error so it can be caught and handled by the component
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
const deleteDocument = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/documents/${id}`, {
|
||||
headers: {
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setDocuments(prevDocuments => prevDocuments.filter(document => document._id !== id));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error deleting document with ID ${id}:`, err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Upload text version for a document
|
||||
*/
|
||||
const uploadTextVersion = useCallback(async (id: string, formData: FormData): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/documents/${id}/textversion`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setDocuments(prevDocuments =>
|
||||
prevDocuments.map(document =>
|
||||
document._id === id ? { ...document, ...response.data.document } : document
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.document;
|
||||
} catch (err) {
|
||||
console.error(`Error uploading text version for document with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update document public status
|
||||
*/
|
||||
const updatePublicStatus = useCallback(async (id: string, isPublic: boolean): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${API_URL}/documents/${id}/public`,
|
||||
{ isPublic },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setDocuments(prevDocuments =>
|
||||
prevDocuments.map(document =>
|
||||
document._id === id ? { ...document, ...response.data.document } : document
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.document;
|
||||
} catch (err) {
|
||||
console.error(`Error updating public status for document with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Mark document as accessibility checked
|
||||
*/
|
||||
const markAccessibilityChecked = useCallback(async (id: string, accessibilityChecked: boolean): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${API_URL}/documents/${id}/accessibility`,
|
||||
{ accessibilityChecked },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setDocuments(prevDocuments =>
|
||||
prevDocuments.map(document =>
|
||||
document._id === id ? { ...document, ...response.data.document } : document
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.document;
|
||||
} catch (err) {
|
||||
console.error(`Error marking document with ID ${id} as accessibility checked:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load documents on initial render
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [fetchDocuments]);
|
||||
|
||||
return {
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchDocuments,
|
||||
getDocumentById,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
uploadTextVersion,
|
||||
updatePublicStatus,
|
||||
markAccessibilityChecked
|
||||
};
|
||||
}
|
||||
76
frontend/src/utils/formatters.ts
Normal file
76
frontend/src/utils/formatters.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Format a date string to a human-readable format
|
||||
* @param dateString Date string to format
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function formatDate(dateString: string | Date): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
|
||||
// Format options
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
};
|
||||
|
||||
return date.toLocaleDateString('en-US', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in bytes to a human-readable format
|
||||
* @param bytes File size in bytes
|
||||
* @returns Formatted file size string
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in seconds to a human-readable format (MM:SS)
|
||||
* @param seconds Duration in seconds
|
||||
* @returns Formatted duration string
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds < 0) {
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with commas for thousands
|
||||
* @param num Number to format
|
||||
* @returns Formatted number string
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a specified length and add ellipsis if needed
|
||||
* @param str String to truncate
|
||||
* @param maxLength Maximum length of the string
|
||||
* @returns Truncated string
|
||||
*/
|
||||
export function truncateString(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.slice(0, maxLength) + '...';
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue