Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94778dca55 | |||
| e9a2508750 | |||
| 27f551ef3a | |||
| 81c55261d6 | |||
| bf68344f0c | |||
| f7e1d067f4 | |||
| d8d7b500ad | |||
| 250fb93021 | |||
| c0bb2aa5d1 |
+13
-12
@@ -108,14 +108,15 @@ steps:
|
||||
- VERSION=${DRONE_TAG}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
# -------- NEW: Create GitHub Release --------
|
||||
- name: github-release
|
||||
image: plugins/github-release
|
||||
# -------- NEW: Create Gitea Release --------
|
||||
- name: gitea-release
|
||||
image: plugins/gitea-release
|
||||
when:
|
||||
event: tag
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITHUB_TOKEN
|
||||
from_secret: GITEA_TOKEN
|
||||
base_url: https://gitea.local.nothaft.cloud
|
||||
files: []
|
||||
title: PicPeak ${DRONE_TAG}
|
||||
note: |
|
||||
@@ -123,27 +124,27 @@ steps:
|
||||
|
||||
## 🐳 Docker Images
|
||||
|
||||
This release includes Docker images published to GitHub Container Registry:
|
||||
This release includes Docker images published to local registry:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:latest
|
||||
docker pull registry.local.nothaft.cloud/picpeak-backend:${DRONE_TAG}
|
||||
docker pull registry.local.nothaft.cloud/picpeak-backend:latest
|
||||
|
||||
# Frontend
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:latest
|
||||
docker pull registry.local.nothaft.cloud/picpeak-frontend:${DRONE_TAG}
|
||||
docker pull registry.local.nothaft.cloud/picpeak-frontend:latest
|
||||
```
|
||||
|
||||
## 📦 What's New
|
||||
|
||||
See the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.
|
||||
See the [README](https://gitea.nothaft.cloud/paul/picpeak#readme) for features and documentation.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
git clone https://gitea.nothaft.cloud/paul/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Use the tagged version
|
||||
@@ -152,7 +153,7 @@ steps:
|
||||
|
||||
---
|
||||
|
||||
For detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).
|
||||
For detailed deployment instructions, see the Deployment Guide in the repository.
|
||||
|
||||
trigger:
|
||||
event:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.88",
|
||||
"version": "1.0.92",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.88",
|
||||
"version": "1.0.92",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.88",
|
||||
"version": "1.0.92",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
EventsListPage,
|
||||
CreateEventPageEnhanced as CreateEventPage,
|
||||
EventDetailsPage,
|
||||
EventFeedbackPage,
|
||||
EmailConfigPage,
|
||||
ArchivesPage,
|
||||
AnalyticsPage,
|
||||
@@ -120,6 +121,7 @@ function App() {
|
||||
<Route path="events" element={<EventsListPage />} />
|
||||
<Route path="events/new" element={<CreateEventPage />} />
|
||||
<Route path="events/:id" element={<EventDetailsPage />} />
|
||||
<Route path="events/:id/feedback" element={<EventFeedbackPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
<Route path="email" element={<EmailConfigPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { de, enUS } from 'date-fns/locale';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { publicSettingsService } from '../services/publicSettings.service';
|
||||
|
||||
// Convert old date format strings to new date-fns format
|
||||
const convertDateFormat = (format: string): string => {
|
||||
return format
|
||||
.replace(/DD/g, 'dd') // Days: DD -> dd
|
||||
.replace(/YYYY/g, 'yyyy') // Years: YYYY -> yyyy
|
||||
.replace(/YY/g, 'yy'); // Short years: YY -> yy
|
||||
};
|
||||
|
||||
export const useLocalizedDate = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
@@ -22,7 +30,18 @@ export const useLocalizedDate = () => {
|
||||
const format = (date: Date | string, formatStr?: string) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
// Use admin-configured date format if available and no format string provided
|
||||
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
|
||||
let dateFormat = formatStr;
|
||||
if (!dateFormat && settings?.general_date_format) {
|
||||
// Handle both string and object formats
|
||||
dateFormat = typeof settings.general_date_format === 'string'
|
||||
? settings.general_date_format
|
||||
: settings.general_date_format.format || 'PPP';
|
||||
}
|
||||
dateFormat = dateFormat || 'PPP';
|
||||
|
||||
// Convert old format to new format
|
||||
dateFormat = convertDateFormat(dateFormat);
|
||||
|
||||
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
|
||||
};
|
||||
|
||||
@@ -35,6 +54,12 @@ export const useLocalizedDate = () => {
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
locale: getLocale(),
|
||||
dateFormat: settings?.general_date_format || 'PPP'
|
||||
dateFormat: settings?.general_date_format
|
||||
? convertDateFormat(
|
||||
typeof settings.general_date_format === 'string'
|
||||
? settings.general_date_format
|
||||
: settings.general_date_format.format || 'PPP'
|
||||
)
|
||||
: 'PPP'
|
||||
};
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -84,7 +84,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
admin_email: '',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -57,7 +57,7 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
|
||||
// Security settings state
|
||||
@@ -99,7 +99,11 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_registration: settings.general_enable_registration || false,
|
||||
maintenance_mode: settings.general_maintenance_mode || false,
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format || { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
|
||||
: settings.general_date_format)
|
||||
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
|
||||
// Extract security settings
|
||||
@@ -130,7 +134,12 @@ export const SettingsPage: React.FC = () => {
|
||||
// Convert to the format expected by the API
|
||||
const settingsData: Record<string, any> = {};
|
||||
Object.entries(generalSettings).forEach(([key, value]) => {
|
||||
settingsData[`general_${key}`] = value;
|
||||
// Special handling for date_format - only send the format string
|
||||
if (key === 'date_format' && typeof value === 'object' && value.format) {
|
||||
settingsData[`general_${key}`] = value.format;
|
||||
} else {
|
||||
settingsData[`general_${key}`] = value;
|
||||
}
|
||||
});
|
||||
return settingsService.updateSettings(settingsData);
|
||||
},
|
||||
@@ -395,10 +404,10 @@ export const SettingsPage: React.FC = () => {
|
||||
{t('settings.general.dateFormat')}
|
||||
</label>
|
||||
<select
|
||||
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
|
||||
value={generalSettings.date_format?.format || 'dd/MM/yyyy'}
|
||||
onChange={(e) => {
|
||||
const format = e.target.value;
|
||||
const locale = format === 'MM/DD/YYYY' ? 'en-US' : 'en-GB';
|
||||
const locale = format === 'MM/dd/yyyy' ? 'en-US' : 'en-GB';
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
date_format: { format, locale }
|
||||
@@ -406,10 +415,10 @@ export const SettingsPage: React.FC = () => {
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
|
||||
<option value="DD.MM.YYYY">DD.MM.YYYY (German)</option>
|
||||
<option value="dd/MM/yyyy">DD/MM/YYYY (European)</option>
|
||||
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
|
||||
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
|
||||
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.dateFormatHelp')}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface PublicSettings {
|
||||
theme_config: any;
|
||||
default_language: string;
|
||||
enable_analytics: boolean;
|
||||
general_date_format: string;
|
||||
general_date_format: string | { format: string; locale: string };
|
||||
enable_recaptcha: boolean;
|
||||
recaptcha_site_key: string | null;
|
||||
maintenance_mode: boolean;
|
||||
|
||||
Reference in New Issue
Block a user