Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s

This commit is contained in:
2025-11-28 13:29:44 +01:00
parent bce5f749b1
commit 9a75f1c929
28 changed files with 1635 additions and 294 deletions
@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedVideo: React.FC<AdminAuthenticatedVideoProps> = ({
src,
fallback,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
const loadVideo = async () => {
try {
setLoading(true);
setError(false);
setVideoSrc(null);
const response = await api.get(src, { responseType: 'blob' });
if (!cancelled) {
objectUrl = URL.createObjectURL(response.data);
setVideoSrc(objectUrl);
setLoading(false);
}
} catch {
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadVideo();
}
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src]);
if (loading) {
return <div className="w-full h-full bg-neutral-200 animate-pulse" />;
}
if (error || !videoSrc) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
@@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onPhotoClick,
onPhotosDeleted
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
{isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
</Button>
{(isSelectionMode || selectedPhotos.size > 0) && (
@@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
size="sm"
onClick={handleSelectAll}
>
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
{selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
</Button>
{selectedPhotos.size > 0 && (
<>
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<button
onClick={handleDeleteSelected}
@@ -150,7 +152,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Selected
{t('gallery.deleteSelected', 'Delete Selected')}
</button>
</>
)}
@@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div>
<div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''}
{t('gallery.photosCount', { count: photos.length })}
</div>
</div>
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const likeCount = photo.like_count ?? 0;
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
return (
<div
key={photo.id}
@@ -259,6 +264,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</span>
</div>
)}
{isVideo && (
<div className="absolute bottom-2 left-2 pointer-events-none">
<span className="px-2 py-1 text-[11px] font-semibold bg-black/70 text-white rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
@@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p>
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div>
)}
</div>
@@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service';
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
type AdminFeedbackResponse = {
feedback: PhotoFeedback[];
@@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const queryClient = useQueryClient();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
const averageRating = currentPhoto?.average_rating ?? 0;
const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0;
@@ -191,19 +197,35 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
{isVideo ? (
<AdminAuthenticatedVideo
src={currentPhoto.url}
className="max-w-full max-h-full bg-black"
poster={currentPhoto.thumbnail_url || undefined}
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load media</p>
</div>
</div>
</div>
}
/>
}
/>
) : (
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
)}
</div>
{/* Sidebar */}
+42 -14
View File
@@ -1,16 +1,20 @@
import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common';
import { useTranslation } from 'react-i18next';
interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
categories: Array<{ id: number | string; name: string; slug: string }>;
selectedCategory: number | string | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onCategoryChange: (categoryId: number | string | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
mediaType?: 'all' | 'photo' | 'video';
onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
@@ -21,8 +25,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
sortOrder,
onCategoryChange,
onSearchChange,
onSortChange
onSortChange,
mediaType = 'all',
onMediaTypeChange,
showMediaFilter = false
}) => {
const { t } = useTranslation();
const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
};
@@ -34,7 +42,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<div className="flex-1">
<Input
type="text"
placeholder="Search by filename..."
placeholder={t('gallery.searchByFilename', 'Search by filename...')}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
@@ -46,11 +54,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') return onCategoryChange(null);
const numeric = Number(raw);
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
}}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Categories</option>
<option value="0">Uncategorized</option>
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
@@ -59,6 +72,21 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</select>
</div>
{showMediaFilter && onMediaTypeChange && (
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={mediaType}
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
<option value="video">{t('gallery.videosOnly', 'Videos only')}</option>
</select>
</div>
)}
{/* Sort Options */}
<div className="flex items-center gap-2">
<select
@@ -66,16 +94,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
<option value="rating">Sort by Rating</option>
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
<option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
<option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
</select>
<button
onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" />
@@ -87,4 +115,4 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</div>
</div>
);
};
};
+24 -12
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import { Upload, X, Image, Loader2, Video } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
@@ -51,12 +51,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
const allowedFiles = files.filter(file => allowedTypes.includes(file.type));
const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type));
if (rejectedFiles.length > 0) {
toast.error(
t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).')
);
}
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
const totalFiles = selectedFiles.length + allowedFiles.length;
if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) {
@@ -70,11 +76,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
setSelectedFiles(prev => [...prev, ...allowedFiles]);
};
const removeFile = (index: number) => {
@@ -186,7 +192,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('upload.photoCategory')}
{t('upload.mediaCategory', 'Media category')}
</label>
<select
value={selectedCategoryId || ''}
@@ -216,7 +222,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p>
<p
className={clsx(
@@ -236,7 +242,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
onChange={handleFileSelect}
className="hidden"
/>
@@ -255,7 +261,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Image className="w-5 h-5 text-neutral-400" />
{file.type.startsWith('video/') ? (
<Video className="w-5 h-5 text-neutral-400" />
) : (
<Image className="w-5 h-5 text-neutral-400" />
)}
<div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name}
@@ -288,7 +298,9 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
>
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
{isUploading
? t('upload.uploading')
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
</Button>
</div>
@@ -33,7 +33,7 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Fixed Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2>
<h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
<Button
variant="ghost"
size="sm"
@@ -56,4 +56,4 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
);
};
PhotoUploadModal.displayName = 'PhotoUploadModal';
PhotoUploadModal.displayName = 'PhotoUploadModal';
+2 -1
View File
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal';
@@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
export { WordFilterManager } from './WordFilterManager';