From 7598f26c9c6b79364273478076ed20eb06e78ca7 Mon Sep 17 00:00:00 2001 From: johnny2211 Date: Sat, 17 Jan 2026 19:31:39 +0100 Subject: [PATCH] Add admin interface for term management with video upload support --- packages/backend/src/middleware/upload.ts | 87 +++++ packages/backend/src/routes/terms.ts | 298 +++++++++++++++++- packages/backend/src/server.ts | 5 +- packages/frontend/src/App.tsx | 9 + .../src/components/admin/TermForm.tsx | 156 +++++++++ .../src/components/admin/VideoUpload.tsx | 244 ++++++++++++++ .../components/dictionary/WordDetailModal.tsx | 22 +- .../src/components/layout/Sidebar.tsx | 60 ++-- packages/frontend/src/pages/AdminTerms.tsx | 243 ++++++++++++++ videos.md | 216 +++++++++++++ 10 files changed, 1315 insertions(+), 25 deletions(-) create mode 100644 packages/backend/src/middleware/upload.ts create mode 100644 packages/frontend/src/components/admin/TermForm.tsx create mode 100644 packages/frontend/src/components/admin/VideoUpload.tsx create mode 100644 packages/frontend/src/pages/AdminTerms.tsx create mode 100644 videos.md diff --git a/packages/backend/src/middleware/upload.ts b/packages/backend/src/middleware/upload.ts new file mode 100644 index 0000000..e17d512 --- /dev/null +++ b/packages/backend/src/middleware/upload.ts @@ -0,0 +1,87 @@ +import multer from 'multer'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { Request } from 'express'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Ensure uploads directory exists +const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos'); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); +} + +// Configure storage +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => { + cb(null, uploadsDir); + }, + filename: (req, file, cb) => { + // Generate unique filename with timestamp + const termId = (req.params as any).id || 'unknown'; + const timestamp = Date.now(); + const ext = path.extname(file.originalname); + const basename = path.basename(file.originalname, ext) + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .substring(0, 30); + + const filename = `${basename}-${termId.substring(0, 8)}-${timestamp}${ext}`; + cb(null, filename); + }, +}); + +// File filter to validate MIME types +const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + const allowedMimeTypes = [ + 'video/mp4', + 'video/webm', + 'video/quicktime', // .mov files + ]; + + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Invalid file type. Only MP4, WebM, and MOV videos are allowed. Received: ${file.mimetype}`)); + } +}; + +// Get max file size from environment or default to 100MB +const maxFileSize = parseInt(process.env.MAX_VIDEO_SIZE || '104857600', 10); // 100MB in bytes + +// Create multer upload instance +export const videoUpload = multer({ + storage, + fileFilter, + limits: { + fileSize: maxFileSize, + }, +}); + +// Error handler middleware for multer errors +export const handleUploadError = (err: any, _req: Request, res: any, next: any) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ + error: 'File too large', + message: `Maximum file size is ${maxFileSize / 1024 / 1024}MB`, + }); + } + return res.status(400).json({ + error: 'Upload error', + message: err.message, + }); + } + + if (err) { + return res.status(400).json({ + error: 'Upload failed', + message: err.message, + }); + } + + next(); +}; + diff --git a/packages/backend/src/routes/terms.ts b/packages/backend/src/routes/terms.ts index 25b082c..b2f0a02 100644 --- a/packages/backend/src/routes/terms.ts +++ b/packages/backend/src/routes/terms.ts @@ -1,6 +1,14 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../lib/prisma.js'; -import { WordType, CefrLevel } from '@prisma/client'; +import { WordType, CefrLevel, MediaKind } from '@prisma/client'; +import { isAuthenticated, isAdmin } from '../middleware/auth.js'; +import { videoUpload, handleUploadError } from '../middleware/upload.js'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const router = Router(); @@ -130,5 +138,293 @@ router.get('/:id', async (req: Request, res: Response) => { } }); +/** + * Helper function to normalize text (lowercase, remove diacritics) + */ +function normalizeText(text: string): string { + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); // Remove diacritics +} + +/** + * POST /api/terms + * Create a new term (Admin only) + */ +router.post('/', isAuthenticated, isAdmin, async (req: Request, res: Response) => { + try { + const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body; + + // Validate required fields + if (!wordText || !wordType || !cefrLevel) { + return res.status(400).json({ + error: 'Validation error', + message: 'wordText, wordType, and cefrLevel are required', + }); + } + + // Validate wordType + const validWordTypes = Object.values(WordType); + if (!validWordTypes.includes(wordType as WordType)) { + return res.status(400).json({ + error: 'Validation error', + message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`, + }); + } + + // Validate cefrLevel + const validCefrLevels = Object.values(CefrLevel); + if (!validCefrLevels.includes(cefrLevel as CefrLevel)) { + return res.status(400).json({ + error: 'Validation error', + message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`, + }); + } + + // Create term with auto-generated normalizedText + const term = await prisma.term.create({ + data: { + wordText, + normalizedText: normalizeText(wordText), + wordType: wordType as WordType, + cefrLevel: cefrLevel as CefrLevel, + shortDescription: shortDescription || null, + tags: tags || null, + iconAssetId: iconAssetId || null, + }, + include: { + media: true, + examples: true, + }, + }); + + res.status(201).json({ term }); + } catch (error: any) { + console.error('Error creating term:', error); + res.status(500).json({ error: 'Failed to create term', message: error.message }); + } +}); + +/** + * PUT /api/terms/:id + * Update an existing term (Admin only) + */ +router.put('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body; + + // Check if term exists + const existingTerm = await prisma.term.findUnique({ where: { id } }); + if (!existingTerm) { + return res.status(404).json({ error: 'Term not found' }); + } + + // Build update data + const updateData: any = {}; + + if (wordText !== undefined) { + updateData.wordText = wordText; + updateData.normalizedText = normalizeText(wordText); + } + + if (wordType !== undefined) { + const validWordTypes = Object.values(WordType); + if (!validWordTypes.includes(wordType as WordType)) { + return res.status(400).json({ + error: 'Validation error', + message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`, + }); + } + updateData.wordType = wordType as WordType; + } + + if (cefrLevel !== undefined) { + const validCefrLevels = Object.values(CefrLevel); + if (!validCefrLevels.includes(cefrLevel as CefrLevel)) { + return res.status(400).json({ + error: 'Validation error', + message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`, + }); + } + updateData.cefrLevel = cefrLevel as CefrLevel; + } + + if (shortDescription !== undefined) updateData.shortDescription = shortDescription || null; + if (tags !== undefined) updateData.tags = tags || null; + if (iconAssetId !== undefined) updateData.iconAssetId = iconAssetId || null; + + // Update term + const term = await prisma.term.update({ + where: { id }, + data: updateData, + include: { + media: true, + examples: true, + }, + }); + + res.json({ term }); + } catch (error: any) { + console.error('Error updating term:', error); + res.status(500).json({ error: 'Failed to update term', message: error.message }); + } +}); + +/** + * DELETE /api/terms/:id + * Delete a term and its associated media (Admin only) + */ +router.delete('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Check if term exists and get associated media + const term = await prisma.term.findUnique({ + where: { id }, + include: { media: true }, + }); + + if (!term) { + return res.status(404).json({ error: 'Term not found' }); + } + + // Delete associated video files from disk + const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos'); + for (const media of term.media) { + if (media.kind === MediaKind.VIDEO && media.url) { + const filename = path.basename(media.url); + const filePath = path.join(uploadsDir, filename); + + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`Deleted video file: ${filename}`); + } + } catch (err) { + console.error(`Failed to delete video file ${filename}:`, err); + } + } + } + + // Delete term (cascade will delete TermMedia records) + await prisma.term.delete({ where: { id } }); + + res.json({ message: 'Term deleted successfully' }); + } catch (error: any) { + console.error('Error deleting term:', error); + res.status(500).json({ error: 'Failed to delete term', message: error.message }); + } +}); + +/** + * POST /api/terms/:id/media + * Upload a video for a term (Admin only) + */ +router.post( + '/:id/media', + isAuthenticated, + isAdmin, + videoUpload.single('video'), + handleUploadError, + async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Check if term exists + const term = await prisma.term.findUnique({ where: { id } }); + if (!term) { + // Clean up uploaded file if term doesn't exist + if (req.file) { + fs.unlinkSync(req.file.path); + } + return res.status(404).json({ error: 'Term not found' }); + } + + // Check if file was uploaded + if (!req.file) { + return res.status(400).json({ error: 'No video file uploaded' }); + } + + // Create relative URL path + const relativeUrl = `/uploads/videos/${req.file.filename}`; + + // Create TermMedia record + const media = await prisma.termMedia.create({ + data: { + termId: id, + kind: MediaKind.VIDEO, + url: relativeUrl, + // TODO: Extract video metadata (duration, dimensions) using ffprobe if available + // For now, these fields will be null + }, + }); + + res.status(201).json({ media }); + } catch (error: any) { + console.error('Error uploading video:', error); + + // Clean up uploaded file on error + if (req.file) { + try { + fs.unlinkSync(req.file.path); + } catch (err) { + console.error('Failed to clean up file:', err); + } + } + + res.status(500).json({ error: 'Failed to upload video', message: error.message }); + } + } +); + +/** + * DELETE /api/terms/:id/media/:mediaId + * Delete a video from a term (Admin only) + */ +router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Request, res: Response) => { + try { + const { id, mediaId } = req.params; + + // Check if media exists and belongs to the term + const media = await prisma.termMedia.findUnique({ + where: { id: mediaId }, + }); + + if (!media) { + return res.status(404).json({ error: 'Media not found' }); + } + + if (media.termId !== id) { + return res.status(400).json({ error: 'Media does not belong to this term' }); + } + + // Delete video file from disk if it's a video + if (media.kind === MediaKind.VIDEO && media.url) { + const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos'); + const filename = path.basename(media.url); + const filePath = path.join(uploadsDir, filename); + + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`Deleted video file: ${filename}`); + } + } catch (err) { + console.error(`Failed to delete video file ${filename}:`, err); + } + } + + // Delete TermMedia record + await prisma.termMedia.delete({ where: { id: mediaId } }); + + res.json({ message: 'Media deleted successfully' }); + } catch (error: any) { + console.error('Error deleting media:', error); + res.status(500).json({ error: 'Failed to delete media', message: error.message }); + } +}); + export default router; diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 691f2ef..6f1a1d0 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -16,7 +16,10 @@ const app = express(); const PORT = parseInt(process.env.PORT || '3000', 10); // Middleware -app.use(helmet()); +app.use(helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: false, // Disable CSP to allow video playback +})); app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173', credentials: true, diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 2af50ed..af74d4e 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import Home from './pages/Home'; import { Login } from './pages/Login'; import { Admin } from './pages/Admin'; +import { AdminTerms } from './pages/AdminTerms'; import Dictionary from './pages/Dictionary'; import Znakopis from './pages/Znakopis'; import { ProtectedRoute } from './components/ProtectedRoute'; @@ -38,6 +39,14 @@ function App() { } /> + + + + } + /> {/* Dictionary */} } /> {/* Znakopis */} diff --git a/packages/frontend/src/components/admin/TermForm.tsx b/packages/frontend/src/components/admin/TermForm.tsx new file mode 100644 index 0000000..920d95e --- /dev/null +++ b/packages/frontend/src/components/admin/TermForm.tsx @@ -0,0 +1,156 @@ +import { useState, FormEvent } from 'react'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import api from '../../lib/api'; +import { Term, WordType, CefrLevel } from '../../types/term'; +import { toast } from 'sonner'; + +interface TermFormProps { + term?: Term; + onSuccess: () => void; + onCancel: () => void; +} + +export function TermForm({ term, onSuccess, onCancel }: TermFormProps) { + const [formData, setFormData] = useState({ + wordText: term?.wordText || '', + wordType: term?.wordType || WordType.NOUN, + cefrLevel: term?.cefrLevel || CefrLevel.A1, + shortDescription: term?.shortDescription || '', + tags: term?.tags || '', + iconAssetId: term?.iconAssetId || '', + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!formData.wordText.trim()) { + toast.error('Riječ je obavezna'); + return; + } + + try { + setIsSubmitting(true); + + if (term) { + // Update existing term + await api.put(`/api/terms/${term.id}`, formData); + toast.success('Riječ uspješno ažurirana'); + } else { + // Create new term + await api.post('/api/terms', formData); + toast.success('Riječ uspješno dodana'); + } + + onSuccess(); + } catch (err: any) { + toast.error(err.response?.data?.message || 'Greška pri spremanju'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + setFormData({ ...formData, wordText: e.target.value })} + placeholder="npr. Dobar dan" + required + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +