Add admin interface for term management with video upload support

This commit is contained in:
2026-01-17 19:31:39 +01:00
parent 85ae57b95b
commit 7598f26c9c
10 changed files with 1315 additions and 25 deletions

View 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();
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 */}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
)}

View File

@@ -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>

View 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>
);
}