diff --git a/frontend/src/components/admin/FeedbackModerationPanel.tsx b/frontend/src/components/admin/FeedbackModerationPanel.tsx
new file mode 100644
index 0000000..18c15ab
--- /dev/null
+++ b/frontend/src/components/admin/FeedbackModerationPanel.tsx
@@ -0,0 +1,205 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+ MessageSquare,
+ Eye,
+ EyeOff,
+ Trash2,
+ AlertCircle,
+ CheckCircle,
+ Clock,
+ User
+} from 'lucide-react';
+import { parseISO } from 'date-fns';
+import { toast } from 'react-toastify';
+
+import { Card, Loading, Button } from '../common';
+import { feedbackService } from '../../services/feedback.service';
+import { useLocalizedDate } from '../../hooks/useLocalizedDate';
+
+interface FeedbackModerationPanelProps {
+ eventId: number;
+ className?: string;
+ compact?: boolean;
+ maxItems?: number;
+}
+
+export const FeedbackModerationPanel: React.FC = ({
+ eventId,
+ className = '',
+ compact = false,
+ maxItems = 5
+}) => {
+ const { t } = useTranslation();
+ const { format } = useLocalizedDate();
+ const queryClient = useQueryClient();
+ const [showAll, setShowAll] = useState(false);
+
+ // Fetch pending feedback
+ const { data: feedbackData, isLoading } = useQuery({
+ queryKey: ['event-feedback-moderation', eventId],
+ queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
+ type: 'comment',
+ status: 'pending',
+ limit: showAll ? 100 : maxItems
+ }),
+ refetchInterval: 30000 // Refresh every 30 seconds
+ });
+
+ // Moderation mutation
+ const moderateMutation = useMutation({
+ mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
+ feedbackService.moderateFeedback(feedbackId, action),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['event-feedback-moderation', eventId] });
+ toast.success(t('feedback.moderationSuccess'));
+ }
+ });
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['event-feedback-moderation', eventId] });
+ toast.success(t('feedback.deleted'));
+ }
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const pendingComments = feedbackData?.feedback || [];
+ const hasPending = pendingComments.length > 0;
+
+ return (
+
+
+
+
+ {t('feedback.pendingModeration', 'Pending Moderation')}
+
+ {hasPending && (
+
+ {pendingComments.length} {t('feedback.pending', 'pending')}
+
+ )}
+
+
+ {!hasPending ? (
+
+
+
{t('feedback.noPendingComments', 'No comments pending moderation')}
+
+ ) : (
+
+ {pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => (
+
+
+
+
+
+
+
+
+
+ {item.guest_name || t('feedback.anonymous', 'Anonymous')}
+
+ •
+
+ {format(parseISO(item.created_at), 'MMM d, h:mm a')}
+
+
+
{item.comment}
+ {item.photo_filename && (
+
+ {t('feedback.onPhoto', 'On photo')}: {item.photo_filename}
+
+ )}
+
+
+
+ {/* Actions */}
+
+ }
+ onClick={() => moderateMutation.mutate({
+ feedbackId: item.id.toString(),
+ action: 'approve'
+ })}
+ isLoading={moderateMutation.isPending}
+ >
+ {t('feedback.approve', 'Approve')}
+
+ }
+ onClick={() => moderateMutation.mutate({
+ feedbackId: item.id.toString(),
+ action: 'hide'
+ })}
+ isLoading={moderateMutation.isPending}
+ >
+ {t('feedback.hide', 'Hide')}
+
+ }
+ onClick={() => {
+ if (confirm(t('feedback.confirmDelete', 'Are you sure you want to delete this comment?'))) {
+ deleteMutation.mutate(item.id.toString());
+ }
+ }}
+ isLoading={deleteMutation.isPending}
+ className="text-red-600 hover:text-red-700 hover:bg-red-50"
+ >
+ {t('common.delete', 'Delete')}
+
+
+
+
+
+ ))}
+
+ {pendingComments.length > maxItems && !showAll && (
+
+ )}
+
+ )}
+
+ {/* Quick link to full feedback page */}
+
+
+
+ );
+};
+
+FeedbackModerationPanel.displayName = 'FeedbackModerationPanel';
\ No newline at end of file
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
index 65bbd0e..39c45b9 100644
--- a/frontend/src/components/admin/index.ts
+++ b/frontend/src/components/admin/index.ts
@@ -27,4 +27,5 @@ export { BackupDashboard } from './BackupDashboard';
export { BackupConfiguration } from './BackupConfiguration';
export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
-export { FeedbackSettings } from './FeedbackSettings';
\ No newline at end of file
+export { FeedbackSettings } from './FeedbackSettings';
+export { FeedbackModerationPanel } from './FeedbackModerationPanel';
\ No newline at end of file
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index a210164..a5917a1 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -18,6 +18,7 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
+import { feedbackService } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
interface GalleryViewProps {
@@ -48,6 +49,7 @@ export const GalleryView: React.FC = ({ slug, event }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState>(new Set());
+ const [feedbackEnabled, setFeedbackEnabled] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
@@ -76,6 +78,25 @@ export const GalleryView: React.FC = ({ slug, event }) => {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
+ // Fetch feedback settings
+ const { data: feedbackSettings } = useQuery({
+ queryKey: ['gallery-feedback-settings', event.id],
+ queryFn: async () => {
+ try {
+ // Use public endpoint to get feedback settings
+ const response = await api.get(`/gallery/${slug}/feedback-settings`);
+ return response.data;
+ } catch (error) {
+ // If endpoint doesn't exist or returns error, default to disabled
+ return { feedback_enabled: false };
+ }
+ },
+ onSuccess: (data) => {
+ setFeedbackEnabled(data?.feedback_enabled || false);
+ },
+ enabled: !!event.id,
+ });
+
// Apply branding settings
useEffect(() => {
if (settingsData) {
@@ -429,6 +450,7 @@ export const GalleryView: React.FC = ({ slug, event }) => {
photos={filteredPhotos}
slug={slug}
categoryId={selectedCategoryId}
+ feedbackEnabled={feedbackEnabled}
isSelectionMode={isSelectionMode}
selectedPhotos={selectedPhotos}
onSelectionChange={setSelectedPhotos}
diff --git a/frontend/src/components/gallery/PhotoGrid.tsx b/frontend/src/components/gallery/PhotoGrid.tsx
index 934fd84..ca7aad9 100644
--- a/frontend/src/components/gallery/PhotoGrid.tsx
+++ b/frontend/src/components/gallery/PhotoGrid.tsx
@@ -15,9 +15,10 @@ interface PhotoGridProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
+ feedbackEnabled?: boolean;
}
-export const PhotoGrid: React.FC = ({ photos, slug, categoryId }) => {
+export const PhotoGrid: React.FC = ({ photos, slug, categoryId, feedbackEnabled = false }) => {
const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
const [selectedPhotos, setSelectedPhotos] = useState>(new Set());
@@ -204,6 +205,7 @@ export const PhotoGrid: React.FC = ({ photos, slug, categoryId }
initialIndex={selectedPhotoIndex}
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
+ feedbackEnabled={feedbackEnabled}
/>
)}
>
diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
index 647b767..ab5ee81 100644
--- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
+++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
@@ -34,6 +34,7 @@ interface PhotoGridWithLayoutsProps {
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
+ feedbackEnabled?: boolean;
}
export const PhotoGridWithLayouts: React.FC = ({
@@ -42,6 +43,7 @@ export const PhotoGridWithLayouts: React.FC = ({
categoryId,
isSelectionMode: parentSelectionMode,
selectedPhotos: parentSelectedPhotos,
+ feedbackEnabled,
onSelectionChange,
onToggleSelectionMode: parentToggleSelectionMode,
showSelectionControls = true,
@@ -259,6 +261,7 @@ export const PhotoGridWithLayouts: React.FC = ({
initialIndex={selectedPhotoIndex}
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
+ feedbackEnabled={feedbackEnabled || false}
/>
)}
>
diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx
index 0fad693..527ec00 100644
--- a/frontend/src/components/gallery/PhotoLightbox.tsx
+++ b/frontend/src/components/gallery/PhotoLightbox.tsx
@@ -10,6 +10,7 @@ interface PhotoLightboxProps {
initialIndex: number;
onClose: () => void;
slug: string;
+ feedbackEnabled?: boolean;
}
export const PhotoLightbox: React.FC = ({
@@ -17,6 +18,7 @@ export const PhotoLightbox: React.FC = ({
initialIndex,
onClose,
slug,
+ feedbackEnabled = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -227,13 +229,15 @@ export const PhotoLightbox: React.FC = ({
-
+ {feedbackEnabled && (
+
+ )}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index ffd1f43..2ae2b26 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -367,6 +367,11 @@
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
+ "viewDetails": "Details anzeigen",
+ "archiveEventAction": "Veranstaltung archivieren",
+ "downloadArchiveAction": "Archiv herunterladen",
+ "deleteEvent": "Veranstaltung löschen",
+ "deleteEventConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung löschen möchten?",
"bulkArchive": "Archivieren",
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index 5c1a675..a58ab3e 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -24,11 +24,12 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common';
-import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal } from '../../components/admin';
+import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
+import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
export const EventDetailsPage: React.FC = () => {
@@ -55,6 +56,15 @@ export const EventDetailsPage: React.FC = () => {
hero_photo_id: null as number | null,
host_name: '',
});
+ const [feedbackSettings, setFeedbackSettings] = useState({
+ feedback_enabled: false,
+ allow_ratings: true,
+ allow_likes: true,
+ allow_comments: true,
+ allow_favorites: true,
+ require_moderation: true,
+ show_public_stats: false
+ });
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
@@ -78,6 +88,16 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id,
});
+ // Fetch feedback settings
+ const { data: eventFeedbackSettings } = useQuery({
+ queryKey: ['admin-event-feedback-settings', id],
+ queryFn: () => feedbackService.getEventFeedbackSettings(id!),
+ enabled: !!id,
+ onSuccess: (data) => {
+ setFeedbackSettings(data);
+ }
+ });
+
// Statistics are now fetched with the event details from the admin API
// Fetch photos (needed for both photos tab and hero photo selector)
@@ -198,7 +218,7 @@ export const EventDetailsPage: React.FC = () => {
setIsEditing(true);
};
- const handleSaveEdit = () => {
+ const handleSaveEdit = async () => {
// Prepare color_theme - if we have a custom theme, serialize it
let themeToSave = editForm.color_theme;
if (currentTheme && currentPresetName === 'custom') {
@@ -240,7 +260,16 @@ export const EventDetailsPage: React.FC = () => {
console.log('Updating event with data:', updateData);
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
+
+ // Update event details
updateMutation.mutate(updateData);
+
+ // Update feedback settings separately
+ try {
+ await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
+ } catch (error) {
+ console.error('Failed to update feedback settings:', error);
+ }
};
const handleCopyLink = async () => {
@@ -518,6 +547,15 @@ export const EventDetailsPage: React.FC = () => {
)}
+
+ {/* Feedback Settings */}
+
+
{t('feedback.settings', 'Feedback Settings')}
+
+
) : (
@@ -788,6 +826,15 @@ export const EventDetailsPage: React.FC = () => {
)}
+ {/* Feedback Moderation Panel */}
+ {!event.is_archived && feedbackSettings?.feedback_enabled && (
+
+ )}
+
{/* Archive Status */}
{event.is_archived ? (