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 */} +
+ + + +
+
+
+
+ ))} + + {pendingComments.length > maxItems && !showAll && ( + + )} +
+ )} + + {/* Quick link to full feedback page */} +
+ + + {t('feedback.viewAllFeedback', 'View all feedback & settings')} + +
+
+
+ ); +}; + +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 ? (