From 0064122eff12029300ab7f95078b5710c3c2d08c Mon Sep 17 00:00:00 2001
From: paul
Date: Thu, 24 Jul 2025 13:46:51 +0200
Subject: [PATCH] feat: add feedback management enhancements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add German translations for event dropdown menu actions
- Add feedback settings to event edit form
- Hide comment button in gallery when feedback is disabled
- Add feedback moderation panel to event details page
Implements:
1. German translation for three dots menu actions (viewDetails, archiveEventAction, etc.)
2. Feedback enable option now visible when editing existing events
3. Comment button in photo lightbox only shows when feedback is enabled
4. Inline comment moderation in admin event detail view
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
.../admin/FeedbackModerationPanel.tsx | 205 ++++++++++++++++++
frontend/src/components/admin/index.ts | 3 +-
.../src/components/gallery/GalleryView.tsx | 22 ++
frontend/src/components/gallery/PhotoGrid.tsx | 4 +-
.../gallery/PhotoGridWithLayouts.tsx | 3 +
.../src/components/gallery/PhotoLightbox.tsx | 18 +-
frontend/src/i18n/locales/de.json | 5 +
frontend/src/pages/admin/EventDetailsPage.tsx | 51 ++++-
8 files changed, 300 insertions(+), 11 deletions(-)
create mode 100644 frontend/src/components/admin/FeedbackModerationPanel.tsx
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 ? (