diff --git a/backend/src/controllers/documentController.ts b/backend/src/controllers/documentController.ts new file mode 100644 index 0000000..d90dd40 --- /dev/null +++ b/backend/src/controllers/documentController.ts @@ -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' + } + }); + } +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index d4b3982..20b824f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) => { diff --git a/backend/src/middleware/upload.ts b/backend/src/middleware/upload.ts index 1e690d6..bf9009a 100644 --- a/backend/src/middleware/upload.ts +++ b/backend/src/middleware/upload.ts @@ -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 }; }; diff --git a/backend/src/models/Document.ts b/backend/src/models/Document.ts new file mode 100644 index 0000000..2321765 --- /dev/null +++ b/backend/src/models/Document.ts @@ -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('Document', DocumentSchema); diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts new file mode 100644 index 0000000..895e406 --- /dev/null +++ b/backend/src/routes/documents.ts @@ -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; diff --git a/backend/src/services/documentProcessor.ts b/backend/src/services/documentProcessor.ts new file mode 100644 index 0000000..7fb8870 --- /dev/null +++ b/backend/src/services/documentProcessor.ts @@ -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 { + 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 { + 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 { + 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); + } + } +} diff --git a/backend/uploads/documents/document-1743031855065-584387717.pdf b/backend/uploads/documents/document-1743031855065-584387717.pdf new file mode 100644 index 0000000..d31c99c Binary files /dev/null and b/backend/uploads/documents/document-1743031855065-584387717.pdf differ diff --git a/backend/uploads/textversions/text-1743031855070.txt b/backend/uploads/textversions/text-1743031855070.txt new file mode 100644 index 0000000..8de39ac --- /dev/null +++ b/backend/uploads/textversions/text-1743031855070.txt @@ -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 + + \ No newline at end of file diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index d0e0d89..44e6e09 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -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 diff --git a/cline_docs/progress.md b/cline_docs/progress.md index e8f7893..417d36f 100644 --- a/cline_docs/progress.md +++ b/cline_docs/progress.md @@ -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 diff --git a/frontend/src/app/admin/documents/[id]/edit/page.tsx b/frontend/src/app/admin/documents/[id]/edit/page.tsx new file mode 100644 index 0000000..8674ecd --- /dev/null +++ b/frontend/src/app/admin/documents/[id]/edit/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = { + 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 ( +
+ + + + +
+ ); + } + + // Error state + if (error) { + return ( +
+
+
+
+ + + +
+
+

+ Error loading document +

+
+

{error.message}

+
+
+
+
+
+ + Back to Documents + +
+
+ ); + } + + // Document not found + if (!document) { + return ( +
+
+ + + +

Document not found

+

+ The document you are looking for does not exist or has been deleted. +

+
+ + Back to Documents + +
+
+
+ ); + } + + return ( +
+
+

Edit Document

+ + Cancel + +
+ + +
+ ); +} diff --git a/frontend/src/app/admin/documents/[id]/page.tsx b/frontend/src/app/admin/documents/[id]/page.tsx new file mode 100644 index 0000000..60dcc00 --- /dev/null +++ b/frontend/src/app/admin/documents/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Confirmation dialog state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // File upload state + const [textVersionFile, setTextVersionFile] = useState(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) => { + 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 ( +
+ + + + +
+ ); + } + + // Error state + if (error) { + return ( +
+
+
+
+ + + +
+
+

+ Error loading document +

+
+

{error.message}

+
+
+
+
+
+ + Back to Documents + +
+
+ ); + } + + // Document not found + if (!document) { + return ( +
+
+ + + +

Document not found

+

+ The document you are looking for does not exist or has been deleted. +

+
+ + Back to Documents + +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

{document.title}

+

+ Uploaded on {formatDate(document.uploadDate)} +

+
+
+ + Edit + + +
+
+ + {/* Document details */} +
+ {/* Main content */} +
+ {/* Document preview */} +
+

Document Preview

+ {document.fileType === 'pdf' ? ( +
+ +

+ Your browser does not support PDF preview. + + Click here to open the PDF + +

+
+
+ ) : ( +
+
+ + + +

+ Preview not available for {document.fileType.toUpperCase()} files +

+ + Download to View + +
+
+ )} + +
+ + {/* Description */} +
+

Description

+

+ {document.description} +

+
+ + {/* Accessibility */} +
+

Accessibility

+
+
+
+

Accessibility Status

+

+ Mark this document as checked for accessibility +

+
+ +
+ +
+
+

Text Version

+

+ {document.hasTextVersion + ? 'This document has a text version available' + : 'No text version available for this document'} +

+
+ {document.hasTextVersion ? ( + + Download Text Version + + ) : ( +
+ + + {textVersionFile && ( + + )} +
+ )} +
+
+
+
+ + {/* Sidebar */} +
+ {/* Document info */} +
+

Document Information

+
+
+

Type

+

+ {getDocumentTypeDisplayName(document.documentType)} +

+
+ + {document.meetingDate && ( +
+

Meeting Date

+

+ {formatDate(document.meetingDate)} +

+
+ )} + +
+

File Type

+

+ {document.fileType.toUpperCase()} +

+
+ +
+

File Size

+

+ {formatFileSize(document.fileSize)} +

+
+ +
+

Original Filename

+

+ {document.originalFilename} +

+
+ +
+

Upload Date

+

+ {formatDate(document.uploadDate)} +

+
+ +
+

Last Modified

+

+ {formatDate(document.lastModified)} +

+
+
+
+ + {/* Visibility */} +
+

Visibility

+
+
+

Public Status

+

+ {document.isPublic + ? 'This document is visible to all users' + : 'This document is only visible to administrators'} +

+
+ +
+
+ + {/* Actions */} +
+

Actions

+
+ + Edit Document + + + + Back to Documents + +
+
+
+
+ + {/* Delete confirmation dialog */} + {showDeleteConfirm && ( +
+
+ + +
+
+
+
+ + + +
+
+

+ Delete Document +

+
+

+ Are you sure you want to delete this document? This action cannot be undone. +

+
+
+
+
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/documents/create/page.tsx b/frontend/src/app/admin/documents/create/page.tsx new file mode 100644 index 0000000..c5e52f3 --- /dev/null +++ b/frontend/src/app/admin/documents/create/page.tsx @@ -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 ( +
+
+

Create New Document

+ + Cancel + +
+ + +
+ ); +} diff --git a/frontend/src/app/admin/documents/page.tsx b/frontend/src/app/admin/documents/page.tsx index e1ca50c..0e69dd7 100644 --- a/frontend/src/app/admin/documents/page.tsx +++ b/frontend/src/app/admin/documents/page.tsx @@ -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 ( -
-

Filter Documents

-
-
- - -
- -
- - -
- -
- - setSearch(e.target.value)} - /> -
-
- -
- - -
-
- ); -}; +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([]); - 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) => { - const checked = event.target.checked; - setSelectAll(checked); + + // Selected documents for bulk actions + const [selectedDocuments, setSelectedDocuments] = useState([]); + + // Confirmation dialog state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [documentToDelete, setDocumentToDelete] = useState(null); + + // Apply filters + const handleFilterChange = (e: React.ChangeEvent) => { + 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 ( - - - - ); - } else if (mimeType.includes('word') || mimeType.includes('document')) { - return ( - - - - ); - } else if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) { - return ( - - - - ); - } else { - return ( - - - - ); - } + + // 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 ( -
-
-
-

Documents

-

- Manage bylaws, meeting minutes, forms, and other documents. -

+
+
+

Documents

+ + Add New Document + +
+ + {/* Filters */} +
+

Filters

+
+
+ + +
+ +
+ + +
+ +
+ + +
-
- + +
- - {/* Filter component */} - - - {/* Bulk actions */} - {selectedDocuments.length > 0 && ( -
-
- {selectedDocuments.length} document{selectedDocuments.length > 1 ? 's' : ''} selected -
-
- - + + {/* Error message */} + {error && ( +
+
+
+ + + +
+
+

+ Error loading documents +

+
+

{error.message}

+
+
)} - + + {/* Loading state */} + {loading && ( +
+ + + + +
+ )} + {/* Documents table */} -
+ {!loading && documents.length > 0 && (
- - - - + + - {filteredDocuments.map((document) => ( - + {documents.map((document) => ( + - - - - + + +
+
0} + onChange={(e) => handleSelectAll(e.target.checked)} /> -
- Document + Title - Category - - Size - - Dates + Type Visibility + + Accessibility + + Date + Actions
-
- handleSelectDocument(document.id, e.target.checked)} - /> - -
+ handleSelectDocument(document._id, e.target.checked)} + />
-
- {getFileIcon(document.mimeType)} +
+ + +
-
-
+
+
{document.title}
- {document.description} -
-
- {document.tags.map((tag) => ( - - {tag} - - ))} + {document.fileType.toUpperCase()} • {formatFileSize(document.fileSize)}
-
{document.category}
-
-
{document.fileSize}
-
-
- Uploaded: {formatDate(document.uploadDate)} -
-
- Modified: {formatDate(document.lastModified)} -
-
- - {document.isPublic ? 'Public' : 'Private'} + + {getDocumentTypeDisplayName(document.documentType)} - +
+ + {document.hasTextVersion && ( + + Text Version + + )} +
+
+ {formatDate(document.uploadDate)} + +
+ View - - + + Edit - @@ -479,13 +429,124 @@ export default function DocumentsPage() {
- - {filteredDocuments.length === 0 && ( -
-

No documents found matching the current filters.

+ )} + + {/* Empty state */} + {!loading && documents.length === 0 && ( +
+ + + +

No documents found

+

+ Get started by creating a new document. +

+
+ + Add New Document +
- )} -
+
+ )} + + {/* Pagination */} + {pagination && pagination.pages > 1 && ( +
+
+ Showing {(pagination.page - 1) * pagination.limit + 1} to{' '} + + {Math.min(pagination.page * pagination.limit, pagination.total)} + {' '} + of {pagination.total} results +
+ +
+ )} + + {/* Delete confirmation dialog */} + {showDeleteConfirm && ( +
+
+ + +
+
+
+
+ + + +
+
+

+ Delete Document +

+
+

+ Are you sure you want to delete this document? This action cannot be undone. +

+
+
+
+
+
+ + +
+
+
+
+ )}
); } diff --git a/frontend/src/app/api/uploads/[...path]/route.ts b/frontend/src/app/api/uploads/[...path]/route.ts index 6e9d07c..a47b557 100644 --- a/frontend/src/app/api/uploads/[...path]/route.ts +++ b/frontend/src/app/api/uploads/[...path]/route.ts @@ -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 diff --git a/frontend/src/components/admin/DocumentForm.tsx b/frontend/src/components/admin/DocumentForm.tsx new file mode 100644 index 0000000..edb7862 --- /dev/null +++ b/frontend/src/components/admin/DocumentForm.tsx @@ -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; + onSubmit: (data: FormData) => Promise; + isEdit?: boolean; +} + +interface FormErrors { + [key: string]: string; +} + +const DocumentForm: React.FC = ({ + initialData = {}, + onSubmit, + isEdit = false +}) => { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [formErrors, setFormErrors] = useState({}); + + // File input refs + const documentInputRef = useRef(null); + const textVersionInputRef = useRef(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(null); + const [textVersionFile, setTextVersionFile] = useState(null); + + // Preview state + const [documentPreview, setDocumentPreview] = useState(null); + const [documentName, setDocumentName] = useState(''); + const [documentSize, setDocumentSize] = useState(0); + const [documentType, setDocumentType] = useState(''); + + // 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 + ) => { + 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) => { + 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 ( +
+ {/* Error alert */} + {error && ( +
+
+
+ + + +
+
+

+ {error} +

+
+
+
+ )} + + {/* Basic Information */} +
+

Basic Information

+
+
+ + + {formErrors.title && ( +

{formErrors.title}

+ )} +
+ +
+ +