feat: add feedback management enhancements
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<FeedbackModerationPanelProps> = ({
|
||||
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 (
|
||||
<Card className={className}>
|
||||
<div className="p-6">
|
||||
<Loading />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingComments = feedbackData?.feedback || [];
|
||||
const hasPending = pendingComments.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{t('feedback.pendingModeration', 'Pending Moderation')}
|
||||
</h2>
|
||||
{hasPending && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{pendingComments.length} {t('feedback.pending', 'pending')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasPending ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<p className="text-neutral-600">{t('feedback.noPendingComments', 'No comments pending moderation')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => (
|
||||
<div key={item.id} className="border border-neutral-200 rounded-lg p-4 hover:bg-neutral-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-neutral-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-neutral-900">
|
||||
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||
</span>
|
||||
<span className="text-neutral-500">•</span>
|
||||
<span className="text-neutral-500">
|
||||
{format(parseISO(item.created_at), 'MMM d, h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-neutral-700">{item.comment}</p>
|
||||
{item.photo_filename && (
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{t('feedback.onPhoto', 'On photo')}: {item.photo_filename}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<CheckCircle className="w-4 h-4" />}
|
||||
onClick={() => moderateMutation.mutate({
|
||||
feedbackId: item.id.toString(),
|
||||
action: 'approve'
|
||||
})}
|
||||
isLoading={moderateMutation.isPending}
|
||||
>
|
||||
{t('feedback.approve', 'Approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
onClick={() => moderateMutation.mutate({
|
||||
feedbackId: item.id.toString(),
|
||||
action: 'hide'
|
||||
})}
|
||||
isLoading={moderateMutation.isPending}
|
||||
>
|
||||
{t('feedback.hide', 'Hide')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
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')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pendingComments.length > maxItems && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full text-center py-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick link to full feedback page */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<a
|
||||
href={`/admin/events/${eventId}/feedback`}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{t('feedback.viewAllFeedback', 'View all feedback & settings')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
FeedbackModerationPanel.displayName = 'FeedbackModerationPanel';
|
||||
@@ -27,4 +27,5 @@ export { BackupDashboard } from './BackupDashboard';
|
||||
export { BackupConfiguration } from './BackupConfiguration';
|
||||
export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||
@@ -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<GalleryViewProps> = ({ slug, event }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(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<GalleryViewProps> = ({ 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<GalleryViewProps> = ({ slug, event }) => {
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectionChange={setSelectedPhotos}
|
||||
|
||||
@@ -15,9 +15,10 @@ interface PhotoGridProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
categoryId?: number | null;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }) => {
|
||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId, feedbackEnabled = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
@@ -204,6 +205,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }
|
||||
initialIndex={selectedPhotoIndex}
|
||||
onClose={() => setSelectedPhotoIndex(null)}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface PhotoGridWithLayoutsProps {
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string;
|
||||
expiresAt?: string;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -42,6 +43,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
feedbackEnabled,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
@@ -259,6 +261,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
initialIndex={selectedPhotoIndex}
|
||||
onClose={() => setSelectedPhotoIndex(null)}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled || false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PhotoLightboxProps {
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
slug: string;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
@@ -17,6 +18,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
initialIndex,
|
||||
onClose,
|
||||
slug,
|
||||
feedbackEnabled = false,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
@@ -227,13 +229,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowFeedback(!showFeedback)}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Toggle feedback"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
{feedbackEnabled && (
|
||||
<button
|
||||
onClick={() => setShowFeedback(!showFeedback)}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Toggle feedback"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<FeedbackSettingsType>({
|
||||
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 = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings', 'Feedback Settings')}</h3>
|
||||
<FeedbackSettings
|
||||
settings={feedbackSettings}
|
||||
onChange={setFeedbackSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="space-y-4">
|
||||
@@ -788,6 +826,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feedback Moderation Panel */}
|
||||
{!event.is_archived && feedbackSettings?.feedback_enabled && (
|
||||
<FeedbackModerationPanel
|
||||
eventId={parseInt(id!)}
|
||||
compact={true}
|
||||
maxItems={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Archive Status */}
|
||||
{event.is_archived ? (
|
||||
<Card padding="md">
|
||||
|
||||
Reference in New Issue
Block a user