Add admin interface for term management with video upload support
This commit is contained in:
87
packages/backend/src/middleware/upload.ts
Normal file
87
packages/backend/src/middleware/upload.ts
Normal file
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/terms"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminTerms />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Dictionary */}
|
||||
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
||||
{/* Znakopis */}
|
||||
|
||||
156
packages/frontend/src/components/admin/TermForm.tsx
Normal file
156
packages/frontend/src/components/admin/TermForm.tsx
Normal file
@@ -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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="wordText">Riječ *</Label>
|
||||
<Input
|
||||
id="wordText"
|
||||
type="text"
|
||||
value={formData.wordText}
|
||||
onChange={(e) => setFormData({ ...formData, wordText: e.target.value })}
|
||||
placeholder="npr. Dobar dan"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="wordType">Vrsta riječi *</Label>
|
||||
<select
|
||||
id="wordType"
|
||||
value={formData.wordType}
|
||||
onChange={(e) => setFormData({ ...formData, wordType: e.target.value as WordType })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
<option value={WordType.NOUN}>Imenica</option>
|
||||
<option value={WordType.VERB}>Glagol</option>
|
||||
<option value={WordType.ADJECTIVE}>Pridjev</option>
|
||||
<option value={WordType.ADVERB}>Prilog</option>
|
||||
<option value={WordType.PRONOUN}>Zamjenica</option>
|
||||
<option value={WordType.PREPOSITION}>Prijedlog</option>
|
||||
<option value={WordType.CONJUNCTION}>Veznik</option>
|
||||
<option value={WordType.INTERJECTION}>Uzvik</option>
|
||||
<option value={WordType.PHRASE}>Fraza</option>
|
||||
<option value={WordType.OTHER}>Ostalo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="cefrLevel">CEFR razina *</Label>
|
||||
<select
|
||||
id="cefrLevel"
|
||||
value={formData.cefrLevel}
|
||||
onChange={(e) => setFormData({ ...formData, cefrLevel: e.target.value as CefrLevel })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
<option value={CefrLevel.A1}>A1</option>
|
||||
<option value={CefrLevel.A2}>A2</option>
|
||||
<option value={CefrLevel.B1}>B1</option>
|
||||
<option value={CefrLevel.B2}>B2</option>
|
||||
<option value={CefrLevel.C1}>C1</option>
|
||||
<option value={CefrLevel.C2}>C2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="shortDescription">Kratak opis</Label>
|
||||
<textarea
|
||||
id="shortDescription"
|
||||
value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value })}
|
||||
placeholder="Kratak opis značenja riječi"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags">Oznake (odvojene zarezom)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
type="text"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="npr. pozdrav, osnovno"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="iconAssetId">ID ikone (opcionalno)</Label>
|
||||
<Input
|
||||
id="iconAssetId"
|
||||
type="text"
|
||||
value={formData.iconAssetId}
|
||||
onChange={(e) => setFormData({ ...formData, iconAssetId: e.target.value })}
|
||||
placeholder="npr. icon-123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Odustani
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Spremanje...' : term ? 'Ažuriraj' : 'Dodaj'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
244
packages/frontend/src/components/admin/VideoUpload.tsx
Normal file
244
packages/frontend/src/components/admin/VideoUpload.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useRef, DragEvent } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Upload, Trash2, X } from 'lucide-react';
|
||||
import api from '../../lib/api';
|
||||
import { Term, MediaKind } from '../../types/term';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface VideoUploadProps {
|
||||
term: Term;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const ALLOWED_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
|
||||
|
||||
export function VideoUpload({ term, onSuccess, onCancel }: VideoUploadProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const existingVideos = term.media?.filter(m => m.kind === MediaKind.VIDEO) || [];
|
||||
|
||||
// Construct full video URL
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const getVideoUrl = (url: string) => url.startsWith('http') ? url : `${API_URL}${url}`;
|
||||
|
||||
const validateFile = (file: File): boolean => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast.error('Nevažeći tip datoteke. Dozvoljeni su samo MP4, WebM i MOV formati.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(`Datoteka je prevelika. Maksimalna veličina je ${MAX_FILE_SIZE / 1024 / 1024}MB.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
if (validateFile(file)) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', selectedFile);
|
||||
|
||||
await api.post(`/api/terms/${term.id}/media`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const progress = progressEvent.total
|
||||
? Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
: 0;
|
||||
setUploadProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Video uspješno učitan');
|
||||
setSelectedFile(null);
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Greška pri učitavanju videa');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVideo = async (mediaId: string) => {
|
||||
if (!confirm('Jeste li sigurni da želite obrisati ovaj video?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/terms/${term.id}/media/${mediaId}`);
|
||||
toast.success('Video uspješno obrisan');
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Greška pri brisanju videa');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Existing Videos */}
|
||||
{existingVideos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Postojeći videozapisi</h3>
|
||||
{existingVideos.map((media) => (
|
||||
<div key={media.id} className="flex items-center gap-4 p-4 border rounded-lg">
|
||||
<video
|
||||
src={getVideoUrl(media.url)}
|
||||
className="w-48 h-32 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600">{media.url}</p>
|
||||
{media.durationMs && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Trajanje: {(media.durationMs / 1000).toFixed(1)}s
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVideo(media.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Dodaj novi video</h3>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".mp4,.webm,.mov,video/mp4,video/webm,video/quicktime"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<video
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
className="w-64 h-48 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{selectedFile.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Veličina: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedFile(null)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Ukloni
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{isUploading ? `Učitavanje... ${uploadProgress}%` : 'Učitaj'}
|
||||
</Button>
|
||||
</div>
|
||||
{isUploading && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Upload className="h-12 w-12 mx-auto text-gray-400" />
|
||||
<div>
|
||||
<p className="text-gray-600">Povucite i ispustite video ovdje</p>
|
||||
<p className="text-sm text-gray-500">ili</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Odaberite datoteku
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">
|
||||
Podržani formati: MP4, WebM, MOV (max {MAX_FILE_SIZE / 1024 / 1024}MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Zatvori
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ export function WordDetailModal({ term, open, onClose, onAddToSentence }: WordDe
|
||||
const videoMedia = term.media?.find(m => m.kind === 'VIDEO');
|
||||
const imageMedia = term.media?.find(m => m.kind === 'IMAGE' || m.kind === 'ILLUSTRATION');
|
||||
|
||||
// Construct full video URL
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const videoUrl = videoMedia?.url.startsWith('http')
|
||||
? videoMedia.url
|
||||
: `${API_URL}${videoMedia?.url}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
@@ -81,16 +87,30 @@ export function WordDetailModal({ term, open, onClose, onAddToSentence }: WordDe
|
||||
{videoMedia && (
|
||||
<div className="bg-gray-100 rounded-lg overflow-hidden">
|
||||
<video
|
||||
src={videoMedia.url}
|
||||
src={videoUrl}
|
||||
controls
|
||||
loop
|
||||
autoPlay
|
||||
muted
|
||||
className="w-full"
|
||||
style={{ maxHeight: '400px' }}
|
||||
onError={(e) => {
|
||||
console.error('Video load error:', {
|
||||
url: videoUrl,
|
||||
originalUrl: videoMedia.url,
|
||||
error: e
|
||||
});
|
||||
}}
|
||||
onLoadedData={() => {
|
||||
console.log('Video loaded successfully:', videoUrl);
|
||||
}}
|
||||
>
|
||||
Vaš preglednik ne podržava video reprodukciju.
|
||||
</video>
|
||||
{/* Debug info - remove in production */}
|
||||
<div className="text-xs text-gray-500 p-2">
|
||||
Video URL: {videoUrl}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Video,
|
||||
Cloud,
|
||||
HelpCircle,
|
||||
Users,
|
||||
MessageSquare,
|
||||
import {
|
||||
Home,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Video,
|
||||
Cloud,
|
||||
HelpCircle,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Bug,
|
||||
Shield,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
@@ -115,18 +114,35 @@ export function Sidebar() {
|
||||
{/* Admin Panel (only for admins) */}
|
||||
{user?.role === 'ADMIN' && (
|
||||
<div className="pt-6">
|
||||
<Link
|
||||
to="/admin"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
location.pathname === '/admin'
|
||||
? 'bg-indigo-700 text-white'
|
||||
: 'text-indigo-100 hover:bg-indigo-500 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Shield className="h-5 w-5" />
|
||||
Admin Panel
|
||||
</Link>
|
||||
<h3 className="px-3 text-xs font-semibold uppercase tracking-wider text-indigo-300 mb-2">
|
||||
Administracija
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to="/admin"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
location.pathname === '/admin'
|
||||
? 'bg-indigo-700 text-white'
|
||||
: 'text-indigo-100 hover:bg-indigo-500 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
Korisnici
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/terms"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
location.pathname === '/admin/terms'
|
||||
? 'bg-indigo-700 text-white'
|
||||
: 'text-indigo-100 hover:bg-indigo-500 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Upravljanje riječima
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
243
packages/frontend/src/pages/AdminTerms.tsx
Normal file
243
packages/frontend/src/pages/AdminTerms.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Layout } from '../components/layout/Layout';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Plus, Pencil, Trash2, Video, Search } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { Term } from '../types/term';
|
||||
import { TermForm } from '../components/admin/TermForm';
|
||||
import { VideoUpload } from '../components/admin/VideoUpload';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function AdminTerms() {
|
||||
const [terms, setTerms] = useState<Term[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingTerm, setEditingTerm] = useState<Term | null>(null);
|
||||
const [uploadingVideoFor, setUploadingVideoFor] = useState<Term | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const fetchTerms = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery) params.append('query', searchQuery);
|
||||
params.append('page', page.toString());
|
||||
params.append('limit', '20');
|
||||
|
||||
const response = await api.get(`/api/terms?${params.toString()}`);
|
||||
setTerms(response.data.terms);
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to fetch terms');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTerms();
|
||||
}, [page, searchQuery]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Jeste li sigurni da želite obrisati ovu riječ?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/terms/${id}`);
|
||||
toast.success('Riječ uspješno obrisana');
|
||||
fetchTerms();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to delete term');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setShowCreateForm(false);
|
||||
setEditingTerm(null);
|
||||
fetchTerms();
|
||||
};
|
||||
|
||||
const handleVideoUploadSuccess = () => {
|
||||
setUploadingVideoFor(null);
|
||||
fetchTerms();
|
||||
};
|
||||
|
||||
if (isLoading && terms.length === 0) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto"></div>
|
||||
<p className="mt-4 text-muted-foreground">Učitavanje...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-foreground">Upravljanje riječima</h1>
|
||||
<Button onClick={() => setShowCreateForm(true)} disabled={showCreateForm || !!editingTerm}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Dodaj riječ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pretraži riječi..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{(showCreateForm || editingTerm) && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{editingTerm ? 'Uredi riječ' : 'Dodaj novu riječ'}
|
||||
</h2>
|
||||
<TermForm
|
||||
term={editingTerm || undefined}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={() => {
|
||||
setShowCreateForm(false);
|
||||
setEditingTerm(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Upload Modal */}
|
||||
{uploadingVideoFor && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
Video za: {uploadingVideoFor.wordText}
|
||||
</h2>
|
||||
<VideoUpload
|
||||
term={uploadingVideoFor}
|
||||
onSuccess={handleVideoUploadSuccess}
|
||||
onCancel={() => setUploadingVideoFor(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terms Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Riječ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Vrsta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
CEFR
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Video
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Akcije
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{terms.map((term) => (
|
||||
<tr key={term.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{term.wordText}</div>
|
||||
{term.shortDescription && (
|
||||
<div className="text-sm text-gray-500">{term.shortDescription}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{term.wordType}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{term.cefrLevel}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
term.media && term.media.length > 0
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{term.media && term.media.length > 0 ? `${term.media.length} video(a)` : 'Nema videa'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setUploadingVideoFor(term)}
|
||||
title="Dodaj/Upravljaj videom"
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingTerm(term)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(term.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Prethodna
|
||||
</Button>
|
||||
<span className="flex items-center px-4">
|
||||
Stranica {page} od {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Sljedeća
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
216
videos.md
Normal file
216
videos.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Video Upload Feature for Admin Panel
|
||||
|
||||
## Overview
|
||||
This document provides AI instructions for implementing video upload functionality for Croatian Sign Language words in the admin panel. Admins should be able to upload videos for existing words and create new words with videos.
|
||||
|
||||
## Context from main-plan.md
|
||||
According to the main plan (lines 60-70, 366-382):
|
||||
- Each Term has associated TermMedia records
|
||||
- TermMedia stores video URLs, duration, dimensions, and checksums
|
||||
- Videos are stored in `/uploads/videos/` directory
|
||||
- Video format: MP4 (H.264) for maximum compatibility
|
||||
- File uploads use Multer middleware (already in dependencies)
|
||||
- Static file serving is already configured at `/uploads`
|
||||
|
||||
## Current State
|
||||
### Backend
|
||||
- ✅ Prisma schema has `Term` and `TermMedia` models
|
||||
- ✅ Static file serving configured for `/uploads` directory
|
||||
- ✅ Multer is in package.json dependencies
|
||||
- ✅ Terms routes exist at `packages/backend/src/routes/terms.ts` (currently read-only)
|
||||
- ✅ Admin authentication middleware exists (`isAuthenticated`, `isAdmin`)
|
||||
- ❌ No upload routes or middleware configured yet
|
||||
- ❌ No admin endpoints for creating/editing terms with media
|
||||
|
||||
### Frontend
|
||||
- ✅ Admin panel exists at `packages/frontend/src/pages/Admin.tsx` (user management)
|
||||
- ✅ Dictionary page displays terms with videos
|
||||
- ✅ WordDetailModal shows videos with autoplay
|
||||
- ❌ No admin UI for managing terms/videos
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Backend Tasks
|
||||
|
||||
#### 1. Create Upload Middleware
|
||||
**File:** `packages/backend/src/middleware/upload.ts`
|
||||
|
||||
Create Multer configuration for video uploads:
|
||||
- Accept video files (mp4, webm, mov)
|
||||
- Store in `uploads/videos/` directory
|
||||
- Generate unique filenames with timestamp
|
||||
- Validate file size (max 100MB from env)
|
||||
- Validate MIME types
|
||||
- Handle errors gracefully
|
||||
|
||||
#### 2. Add Admin Term Routes
|
||||
**File:** `packages/backend/src/routes/terms.ts`
|
||||
|
||||
Add the following admin-only endpoints:
|
||||
|
||||
**POST /api/terms** (Admin only)
|
||||
- Create new term with metadata
|
||||
- Fields: wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId
|
||||
- Return created term with ID
|
||||
|
||||
**PUT /api/terms/:id** (Admin only)
|
||||
- Update existing term metadata
|
||||
- Validate term exists
|
||||
- Return updated term
|
||||
|
||||
**DELETE /api/terms/:id** (Admin only)
|
||||
- Delete term and cascade to TermMedia
|
||||
- Optionally delete associated video files from disk
|
||||
- Return success message
|
||||
|
||||
**POST /api/terms/:id/media** (Admin only)
|
||||
- Upload video file using Multer middleware
|
||||
- Create TermMedia record linked to term
|
||||
- Extract video metadata (duration, dimensions) if possible
|
||||
- Store relative URL path (e.g., `/uploads/videos/filename.mp4`)
|
||||
- Return created TermMedia record
|
||||
|
||||
**DELETE /api/terms/:id/media/:mediaId** (Admin only)
|
||||
- Delete TermMedia record
|
||||
- Optionally delete video file from disk
|
||||
- Return success message
|
||||
|
||||
#### 3. Update Server Routes
|
||||
**File:** `packages/backend/src/server.ts`
|
||||
|
||||
Ensure upload routes are registered (if creating separate upload routes file).
|
||||
|
||||
### Frontend Tasks
|
||||
|
||||
#### 1. Create Admin Terms Management Page
|
||||
**File:** `packages/frontend/src/pages/AdminTerms.tsx`
|
||||
|
||||
Create a new admin page for managing terms:
|
||||
- List all terms in a table with search/filter
|
||||
- Show term details: word, type, CEFR level, video status
|
||||
- Actions: Edit, Delete, Upload Video
|
||||
- "Create New Term" button
|
||||
- Pagination for large datasets
|
||||
|
||||
#### 2. Create Term Form Component
|
||||
**File:** `packages/frontend/src/components/admin/TermForm.tsx`
|
||||
|
||||
Form for creating/editing terms:
|
||||
- Input: Word Text (required)
|
||||
- Select: Word Type (NOUN, VERB, ADJECTIVE, etc.)
|
||||
- Select: CEFR Level (A1, A2, B1, B2, C1, C2)
|
||||
- Textarea: Short Description
|
||||
- Input: Tags (comma-separated or tag input)
|
||||
- Input: Icon Asset ID (optional)
|
||||
- Submit button
|
||||
- Cancel button
|
||||
- Validation with error messages
|
||||
|
||||
#### 3. Create Video Upload Component
|
||||
**File:** `packages/frontend/src/components/admin/VideoUpload.tsx`
|
||||
|
||||
Video upload interface:
|
||||
- File input accepting video files (.mp4, .webm, .mov)
|
||||
- Drag-and-drop zone
|
||||
- Upload progress indicator
|
||||
- Preview uploaded video
|
||||
- Display existing video if present
|
||||
- Delete video button
|
||||
- File size validation (client-side)
|
||||
- Success/error notifications
|
||||
|
||||
#### 4. Update Admin Navigation
|
||||
**File:** `packages/frontend/src/components/layout/Sidebar.tsx`
|
||||
|
||||
Add navigation link to Admin Terms page:
|
||||
- Show only for admin users
|
||||
- Label: "Upravljanje riječima" (Word Management)
|
||||
- Icon: Book or Video icon
|
||||
- Place near existing "User Management" link
|
||||
|
||||
#### 5. Update Routing
|
||||
**File:** `packages/frontend/src/App.tsx`
|
||||
|
||||
Add route for admin terms page:
|
||||
- Path: `/admin/terms`
|
||||
- Protected route (admin only)
|
||||
- Component: AdminTerms
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
### New Backend Endpoints
|
||||
```
|
||||
POST /api/terms - Create term (admin)
|
||||
PUT /api/terms/:id - Update term (admin)
|
||||
DELETE /api/terms/:id - Delete term (admin)
|
||||
POST /api/terms/:id/media - Upload video (admin)
|
||||
DELETE /api/terms/:id/media/:mediaId - Delete video (admin)
|
||||
```
|
||||
|
||||
### Existing Endpoints (Read-only)
|
||||
```
|
||||
GET /api/terms - List terms (public)
|
||||
GET /api/terms/:id - Get term details (public)
|
||||
```
|
||||
|
||||
## Database Schema Reference
|
||||
|
||||
### Term Model
|
||||
```prisma
|
||||
model Term {
|
||||
id String @id @default(uuid())
|
||||
wordText String @map("word_text")
|
||||
normalizedText String @map("normalized_text")
|
||||
language String @default("hr")
|
||||
wordType WordType @map("word_type")
|
||||
cefrLevel CefrLevel @map("cefr_level")
|
||||
shortDescription String? @map("short_description")
|
||||
tags String? // JSON array
|
||||
iconAssetId String? @map("icon_asset_id")
|
||||
media TermMedia[]
|
||||
// ... relations
|
||||
}
|
||||
```
|
||||
|
||||
### TermMedia Model
|
||||
```prisma
|
||||
model TermMedia {
|
||||
id String @id @default(uuid())
|
||||
termId String @map("term_id")
|
||||
kind MediaKind // VIDEO, IMAGE, ILLUSTRATION
|
||||
url String
|
||||
durationMs Int? @map("duration_ms")
|
||||
width Int?
|
||||
height Int?
|
||||
checksum String?
|
||||
term Term @relation(...)
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **File Storage**: Videos stored in `packages/backend/uploads/videos/`
|
||||
2. **URL Format**: Store relative URLs like `/uploads/videos/dobar-dan-1234567890.mp4`
|
||||
3. **Normalized Text**: Auto-generate from wordText (lowercase, remove diacritics)
|
||||
4. **Video Metadata**: Extract duration/dimensions server-side if possible (use ffprobe or similar)
|
||||
5. **Error Handling**: Validate file types, sizes, and handle upload failures gracefully
|
||||
6. **Security**: Ensure only admins can upload/delete, validate file types to prevent malicious uploads
|
||||
7. **Croatian Labels**: Use Croatian text in UI (Riječ, Dodaj video, Obriši, etc.)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Admin can create new term without video
|
||||
- [ ] Admin can create new term with video upload
|
||||
- [ ] Admin can edit existing term metadata
|
||||
- [ ] Admin can upload video to existing term
|
||||
- [ ] Admin can replace existing video
|
||||
- [ ] Admin can delete video from term
|
||||
- [ ] Admin can delete entire term
|
||||
- [ ] Video appears in Dictionary (Riječi) page
|
||||
- [ ] Video plays in WordDetailModal
|
||||
- [ ] Non-admin users cannot access admin endpoints
|
||||
- [ ] File size limits are enforced
|
||||
- [ ] Invalid file types are rejected
|
||||
- [ ] Upload progress is shown
|
||||
- [ ] Success/error messages display correctly
|
||||
|
||||
Reference in New Issue
Block a user