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

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