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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user