Compare commits

...

17 Commits

Author SHA1 Message Date
Gitea Actions Bot 591a119ab3 chore: bump version to 1.0.93 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-24 13:24:16 +00:00
paul c7cb96aaab fix: resolve multiple feedback management issues
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m22s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m1s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Hide "Manage Feedback" button when feedback is disabled for an event
- Fix 500 error on feedback API endpoint by adding null-safe operators
- Fix TypeError on analytics page by calculating average_rating in backend
- Fix password validation for event creation by properly awaiting async validation
- Add proper null checks and fallbacks for feedback statistics

These fixes ensure:
- Date passwords like "19.07.2025" work with simple password complexity settings
- Feedback management page loads without errors
- Analytics display correctly even with no feedback data

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 15:19:06 +02:00
Gitea Actions Bot 94778dca55 chore: bump frontend version to 1.0.92
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-24 12:22:36 +00:00
paul e9a2508750 fix: add missing route for feedback management page
Mirror to GitHub / mirror (push) Successful in 50s
Test and Lint / backend-test (push) Successful in 1m37s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Added /admin/events/:id/feedback route to App.tsx
- This fixes the empty page issue when navigating to feedback management
- EventFeedbackPage component was already implemented but route was missing

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 14:17:00 +02:00
Gitea Actions Bot 27f551ef3a chore: bump frontend version to 1.0.91
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-24 11:52:24 +00:00
paul 81c55261d6 feat: add feedback management enhancements
Mirror to GitHub / mirror (push) Successful in 43s
Test and Lint / backend-test (push) Successful in 1m45s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 44s
Version and Release / trigger-drone (push) Successful in 3s
- 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>
2025-07-24 13:46:51 +02:00
paul bf68344f0c fix: use plugins/gitea-release for Drone CI/CD
Mirror to GitHub / mirror (push) Successful in 48s
Test and Lint / backend-test (push) Successful in 1m50s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m41s
- Replace plugins/github-release with plugins/gitea-release
- Fix API endpoint compatibility issue (was using GitHub API v3)
- Update base_url to gitea.local.nothaft.cloud
- Change secret from GITHUB_TOKEN to GITEA_TOKEN
- Update release notes to reference local Gitea URLs

This fixes the 401 authentication error when creating releases
on Gitea instances.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 13:19:25 +02:00
Gitea Actions Bot f7e1d067f4 chore: bump frontend version to 1.0.90
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-24 10:05:36 +00:00
paul d8d7b500ad fix: auto-convert old date formats to new date-fns syntax
Mirror to GitHub / mirror (push) Successful in 51s
Test and Lint / backend-test (push) Successful in 1m42s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 55s
Version and Release / trigger-drone (push) Successful in 3s
- Add convertDateFormat function to automatically fix DD->dd, YYYY->yyyy
- Handles existing database values with old format strings
- Prevents RangeError when using old formats stored in settings
- Ensures backward compatibility without requiring database updates

This fix converts formats on-the-fly so existing production data
with old formats like 'DD.MM.YYYY' will work correctly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 11:59:20 +02:00
Gitea Actions Bot 250fb93021 chore: bump frontend version to 1.0.89
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-24 09:50:52 +00:00
paul c0bb2aa5d1 fix: resolve date formatting error in event creation
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m3s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 3s
- Fix TypeError "e.match is not a function" when creating events
- Update useLocalizedDate hook to handle both string and object date formats
- Add type safety for date format configuration
- Fix date format strings to use correct date-fns format (lowercase)
- Ensure backward compatibility with existing date settings

The issue was caused by SettingsPage saving date formats as objects
while useLocalizedDate expected strings. This fix handles both formats
gracefully and prevents the error page redirect.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 11:45:47 +02:00
Gitea Actions Bot 8b0c1655a1 chore: bump frontend version to 1.0.88
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-07-23 10:10:42 +00:00
paul ff950244d5 fix: complete restore page translations and fix structure
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 45s
Version and Release / trigger-drone (push) Successful in 3s
continuous-integration/drone/push Build is passing
- Fix restoreTypes translation structure (was under options.types)
- Add missing restore.messages.restoreStarted translation
- Ensure all restore wizard strings use translations
- Add corresponding German translations for restore section
- Fix translation key structure to match component expectations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 12:05:48 +02:00
Gitea Actions Bot cd3d6fc704 chore: bump frontend version to 1.0.87
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-23 10:03:33 +00:00
paul c536fe45ff fix: complete backup page translations and improve UI
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m32s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m56s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Fix '0 files' hardcoded string to use translation
- Fix 'local' destination type to show translated name
- Add missing field placeholders for rsync and S3 configurations
- Add missing backup.history.columns.* translations
- Add missing backup.history.filter.* translations
- Add missing backup.history.details.* translations
- Fix backup destination display to use proper translation key
- Replace TestTube icon with Wifi icon for connection testing
- Add all corresponding German translations
- Ensure Backup Health and Coverage titles use translations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 11:58:31 +02:00
paul 42c8ef1c0e docs: add minimum system requirements section to README
Mirror to GitHub / mirror (push) Successful in 47s
Test and Lint / backend-test (push) Successful in 1m35s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m58s
- Add CPU, RAM, and storage requirements
- Include OS and software dependencies
- Add Docker requirements for containerized deployment
- Keep it concise and focused on minimum requirements only

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 11:18:27 +02:00
paul d07d742528 fix: use plugins/github-release for Drone CI/CD
Mirror to GitHub / mirror (push) Successful in 50s
Test and Lint / backend-test (push) Successful in 1m46s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
- Replace manual curl approach with plugins/github-release
- Fixes shell parsing issues with multiline strings
- Properly passes GITHUB_TOKEN via api_key setting
- Uses YAML multiline string (|) for release notes
- Cleaner and more reliable approach

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 10:56:46 +02:00
27 changed files with 566 additions and 92 deletions
+44 -23
View File
@@ -108,31 +108,52 @@ steps:
- VERSION=${DRONE_TAG}
- VITE_API_URL=${VITE_API_URL:-/api}
# -------- NEW: Create GitHub Release --------
- name: github-release
image: curlimages/curl:latest
# -------- NEW: Create Gitea Release --------
- name: gitea-release
image: plugins/gitea-release
when:
event: tag
environment:
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
commands:
- >
echo '{
"tag_name": "'${DRONE_TAG}'",
"target_commitish": "main",
"name": "PicPeak '${DRONE_TAG}'",
"body": "# PicPeak '${DRONE_TAG}'\n\n## 🐳 Docker Images\n\nThis release includes Docker images published to GitHub Container Registry:\n\n```bash\n# Backend\ndocker pull ghcr.io/the-luap/picpeak-backend:'${DRONE_TAG}'\ndocker pull ghcr.io/the-luap/picpeak-backend:latest\n\n# Frontend\ndocker pull ghcr.io/the-luap/picpeak-frontend:'${DRONE_TAG}'\ndocker pull ghcr.io/the-luap/picpeak-frontend:latest\n```\n\n## 📦 What'"'"'s New\n\nSee the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.\n\n## 🚀 Quick Start\n\n```bash\n# Clone and deploy\ngit clone https://github.com/the-luap/picpeak.git\ncd picpeak\n\n# Use the tagged version\ndocker-compose -f docker-compose.prod.yml up -d\n```\n\n---\n\nFor detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).",
"draft": false,
"prerelease": false
}' > /tmp/release.json
- >
curl -X POST
-H "Accept: application/vnd.github+json"
-H "Authorization: Bearer ${GITHUB_TOKEN}"
-H "X-GitHub-Api-Version: 2022-11-28"
https://api.github.com/repos/the-luap/picpeak/releases
-d @/tmp/release.json
settings:
api_key:
from_secret: GITEA_TOKEN
base_url: https://gitea.local.nothaft.cloud
files: []
title: PicPeak ${DRONE_TAG}
note: |
# PicPeak ${DRONE_TAG}
## 🐳 Docker Images
This release includes Docker images published to local registry:
```bash
# Backend
docker pull registry.local.nothaft.cloud/picpeak-backend:${DRONE_TAG}
docker pull registry.local.nothaft.cloud/picpeak-backend:latest
# Frontend
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://gitea.nothaft.cloud/paul/picpeak#readme) for features and documentation.
## 🚀 Quick Start
```bash
# Clone and deploy
git clone https://gitea.nothaft.cloud/paul/picpeak.git
cd picpeak
# Use the tagged version
docker-compose -f docker-compose.prod.yml up -d
```
---
For detailed deployment instructions, see the Deployment Guide in the repository.
trigger:
event:
+14
View File
@@ -94,6 +94,20 @@ Perfect for:
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💻 System Requirements
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: 2GB minimum
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
- **Database**: SQLite (included) or PostgreSQL 12+
### Docker Requirements (Recommended)
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.84",
"version": "1.0.93",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.84",
"version": "1.0.93",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.84",
"version": "1.0.93",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+1 -1
View File
@@ -42,7 +42,7 @@ router.post('/', adminAuth, [
} = req.body;
// Validate password strength for gallery
const passwordValidation = validatePasswordInContext(password, 'gallery', {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
+1 -1
View File
@@ -53,7 +53,7 @@ router.post('/', adminAuth, [
} = req.body;
// Validate password strength
const passwordValidation = validatePasswordInContext(password, 'gallery', {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
+31 -3
View File
@@ -123,8 +123,8 @@ router.get('/events/:eventId/feedback',
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount.count || 0,
pages: Math.ceil((totalCount.count || 0) / limit)
total: totalCount?.count || 0,
pages: Math.ceil((totalCount?.count || 0) / limit)
}
});
} catch (error) {
@@ -182,7 +182,35 @@ router.get('/events/:eventId/feedback-analytics',
const { eventId } = req.params;
// Get summary statistics
const summary = await feedbackService.getEventFeedbackSummary(eventId);
const summaryData = await feedbackService.getEventFeedbackSummary(eventId);
// Calculate average rating and other summary stats
const avgRatingResult = await db('photo_feedback')
.where('event_id', eventId)
.where('feedback_type', 'rating')
.avg('rating as average_rating')
.first();
const pendingModeration = await db('photo_feedback')
.where('event_id', eventId)
.where('feedback_type', 'comment')
.where('is_approved', false)
.where('is_hidden', false)
.count('* as count')
.first();
const summary = {
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
total_ratings: summaryData.stats?.total_ratings || 0,
total_likes: summaryData.stats?.total_likes || 0,
total_comments: summaryData.stats?.total_comments || 0,
total_favorites: summaryData.stats?.total_favorites || 0,
pending_moderation: pendingModeration?.count || 0,
total_feedback: (summaryData.stats?.total_ratings || 0) +
(summaryData.stats?.total_likes || 0) +
(summaryData.stats?.total_comments || 0) +
(summaryData.stats?.total_favorites || 0)
};
// Get top-rated photos
const topRated = await db('photos')
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.86",
"version": "1.0.93",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.86",
"version": "1.0.93",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.86",
"version": "1.0.93",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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 />} />
@@ -12,7 +12,7 @@ import {
Info,
Eye,
EyeOff,
TestTube,
Wifi,
CheckCircle,
XCircle,
Loader2,
@@ -386,7 +386,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
</>
) : (
<>
<TestTube className="mr-2 h-4 w-4" />
<Wifi className="mr-2 h-4 w-4" />
{t('backup.actions.testConnection')}
</>
)}
@@ -183,7 +183,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
label={t('backup.dashboard.stats.backupSize')}
value={formatBytes(statistics.total_size || 0)}
color="green"
subtext={`${statistics.files_processed || 0} files`}
subtext={`${statistics.files_processed || 0} ${t('backup.dashboard.stats.files')}`}
/>
<StatCard
@@ -294,7 +294,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
<div>
<p className="font-medium text-gray-900">
{config?.backup_destination_type
? config.backup_destination_type.toUpperCase()
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
: t('backup.dashboard.notConfigured.title')}
</p>
<p className="text-sm text-gray-500">
@@ -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';
@@ -187,8 +187,8 @@ export const RestoreWizard = () => {
const renderSourceSelection = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Select Backup Source</h3>
<p className="text-sm text-gray-600">Choose where to restore the backup from</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.source.title')}</h3>
<p className="text-sm text-gray-600">{t('backup.restore.source.subtitle')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -203,8 +203,8 @@ export const RestoreWizard = () => {
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">Local Backup</h4>
<p className="text-xs text-gray-500 mt-1">Restore from local filesystem</p>
<h4 className="font-medium text-gray-900">{t('backup.restore.source.local.name')}</h4>
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.local.description')}</p>
</button>
<button
@@ -218,8 +218,8 @@ export const RestoreWizard = () => {
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">S3 Storage</h4>
<p className="text-xs text-gray-500 mt-1">Restore from S3 bucket</p>
<h4 className="font-medium text-gray-900">{t('backup.restore.source.s3.name')}</h4>
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.s3.description')}</p>
</button>
<button
@@ -233,8 +233,8 @@ export const RestoreWizard = () => {
<Upload className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">Upload Backup</h4>
<p className="text-xs text-gray-500 mt-1">Upload a backup file</p>
<h4 className="font-medium text-gray-900">{t('backup.restore.source.upload.name')}</h4>
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.upload.description')}</p>
</button>
</div>
+2 -1
View File
@@ -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>
+27 -2
View File
@@ -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'
};
};
+54 -7
View File
@@ -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.",
@@ -978,7 +983,7 @@
"storageDestination": "Speicherziel",
"nextScheduledBackup": "Nächstes geplantes Backup",
"backupType": "{{type}} Backup",
"noBackupsYet": "Noch keine Backups"
"noDestinationSet": "Kein Ziel festgelegt"
},
"configuration": {
"enableBackup": "Automatisierte Backups aktivieren",
@@ -1000,24 +1005,34 @@
"fields": {
"destinationPath": "Zielpfad",
"destinationPathHelp": "Lokaler Verzeichnispfad für Backup-Speicherung",
"destinationPathPlaceholder": "/pfad/zum/backup/verzeichnis",
"rsyncHost": "Remote-Host",
"rsyncHostHelp": "SSH-Hostname oder IP-Adresse",
"rsyncHostPlaceholder": "backup.beispiel.de",
"rsyncUser": "SSH-Benutzer",
"rsyncUserHelp": "Benutzername für SSH-Verbindung",
"rsyncUserPlaceholder": "backup-benutzer",
"rsyncPath": "Remote-Pfad",
"rsyncPathHelp": "Verzeichnispfad auf Remote-Server",
"rsyncPathPlaceholder": "/home/backup/foto-sharing",
"rsyncSshKey": "SSH Private Key",
"rsyncSshKeyHelp": "SSH Private Key für Authentifizierung (optional)",
"rsyncSshKeyPlaceholder": "-----BEGIN RSA PRIVATE KEY-----",
"s3Endpoint": "S3-Endpunkt",
"s3EndpointHelp": "S3 API-Endpunkt (z.B. s3.amazonaws.com)",
"s3EndpointPlaceholder": "https://s3.amazonaws.com",
"s3Bucket": "Bucket-Name",
"s3BucketHelp": "S3-Bucket für Backup-Speicherung",
"s3BucketPlaceholder": "mein-backup-bucket",
"s3AccessKey": "Zugriffsschlüssel-ID",
"s3AccessKeyHelp": "AWS/S3 Zugriffsschlüssel-ID",
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
"s3SecretKey": "Geheimer Zugriffsschlüssel",
"s3SecretKeyHelp": "AWS/S3 geheimer Zugriffsschlüssel",
"s3SecretKeyPlaceholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"s3Region": "Region",
"s3RegionHelp": "S3-Region (z.B. eu-central-1)"
"s3RegionHelp": "S3-Region (z.B. eu-central-1)",
"s3RegionPlaceholder": "eu-central-1"
},
"schedule": {
"title": "Backup-Zeitplan",
@@ -1098,6 +1113,14 @@
"duration": "Dauer",
"actions": "Aktionen"
},
"columns": {
"status": "Status",
"dateTime": "Datum & Zeit",
"type": "Typ",
"size": "Größe",
"duration": "Dauer",
"actions": "Aktionen"
},
"details": "Details",
"statistics": "Statistiken",
"errors": "Fehler",
@@ -1116,6 +1139,27 @@
"showing": "Zeige {{from}}-{{to}} von {{total}} Backups",
"previous": "Zurück",
"next": "Weiter"
},
"filter": {
"allStatus": "Alle Status",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen",
"running": "Läuft",
"partial": "Teilweise"
},
"noBackupsFound": "Keine Backups gefunden",
"backupsWillAppear": "Backups werden hier angezeigt, sobald sie erstellt wurden",
"messages": {
"deleteSuccess": "Backup erfolgreich gelöscht"
},
"details": {
"backupDetails": "Backup-Details",
"destination": "Ziel",
"started": "Gestartet",
"completed": "Abgeschlossen",
"contentBackedUp": "Gesicherter Inhalt",
"errorDetails": "Fehlerdetails",
"manifest": "Manifest"
}
},
"restore": {
@@ -1159,10 +1203,7 @@
"enterPassphrase": "Verschlüsselungs-Passphrase eingeben",
"at": "um"
},
"options": {
"title": "Wiederherstellungsoptionen",
"subtitle": "Wählen Sie, was wiederhergestellt werden soll",
"types": {
"restoreTypes": {
"full": {
"name": "Vollständige Wiederherstellung",
"description": "Alles wiederherstellen, einschließlich Datenbank, Fotos und Archive",
@@ -1183,7 +1224,10 @@
"description": "Bestimmte Elemente zur Wiederherstellung auswählen",
"warning": "Nur ausgewählte Elemente werden wiederhergestellt"
}
},
},
"options": {
"title": "Wiederherstellungsoptionen",
"subtitle": "Wählen Sie, was wiederhergestellt werden soll",
"additionalOptions": {
"title": "Zusätzliche Optionen",
"skipPreBackup": "Vor-Wiederherstellungs-Backup überspringen",
@@ -1246,6 +1290,9 @@
"starting": "Starte...",
"validating": "Validiere...",
"startNewRestore": "Neue Wiederherstellung starten"
},
"messages": {
"restoreStarted": "Wiederherstellung erfolgreich gestartet"
}
},
"messages": {
+48 -6
View File
@@ -1056,24 +1056,34 @@
"fields": {
"destinationPath": "Destination Path",
"destinationPathHelp": "Local directory path for storing backups",
"destinationPathPlaceholder": "/path/to/backup/directory",
"rsyncHost": "Remote Host",
"rsyncHostHelp": "SSH hostname or IP address",
"rsyncHostPlaceholder": "backup.example.com",
"rsyncUser": "SSH User",
"rsyncUserHelp": "Username for SSH connection",
"rsyncUserPlaceholder": "backup-user",
"rsyncPath": "Remote Path",
"rsyncPathHelp": "Directory path on remote server",
"rsyncPathPlaceholder": "/home/backup/photo-sharing",
"rsyncSshKey": "SSH Private Key",
"rsyncSshKeyHelp": "SSH private key for authentication (optional)",
"rsyncSshKeyPlaceholder": "-----BEGIN RSA PRIVATE KEY-----",
"s3Endpoint": "S3 Endpoint",
"s3EndpointHelp": "S3 API endpoint (e.g., s3.amazonaws.com)",
"s3EndpointPlaceholder": "https://s3.amazonaws.com",
"s3Bucket": "Bucket Name",
"s3BucketHelp": "S3 bucket for storing backups",
"s3BucketPlaceholder": "my-backup-bucket",
"s3AccessKey": "Access Key ID",
"s3AccessKeyHelp": "AWS/S3 access key ID",
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
"s3SecretKey": "Secret Access Key",
"s3SecretKeyHelp": "AWS/S3 secret access key",
"s3SecretKeyPlaceholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"s3Region": "Region",
"s3RegionHelp": "S3 region (e.g., us-east-1)"
"s3RegionHelp": "S3 region (e.g., us-east-1)",
"s3RegionPlaceholder": "us-east-1"
},
"schedule": {
"title": "Backup Schedule",
@@ -1154,6 +1164,14 @@
"duration": "Duration",
"actions": "Actions"
},
"columns": {
"status": "Status",
"dateTime": "Date & Time",
"type": "Type",
"size": "Size",
"duration": "Duration",
"actions": "Actions"
},
"details": "Details",
"statistics": "Statistics",
"errors": "Errors",
@@ -1172,6 +1190,27 @@
"showing": "Showing {{from}}-{{to}} of {{total}} backups",
"previous": "Previous",
"next": "Next"
},
"filter": {
"allStatus": "All Status",
"completed": "Completed",
"failed": "Failed",
"running": "Running",
"partial": "Partial"
},
"noBackupsFound": "No backups found",
"backupsWillAppear": "Backups will appear here once created",
"messages": {
"deleteSuccess": "Backup deleted successfully"
},
"details": {
"backupDetails": "Backup Details",
"destination": "Destination",
"started": "Started",
"completed": "Completed",
"contentBackedUp": "Content Backed Up",
"errorDetails": "Error Details",
"manifest": "Manifest"
}
},
"restore": {
@@ -1215,10 +1254,7 @@
"enterPassphrase": "Enter encryption passphrase",
"at": "at"
},
"options": {
"title": "Restore Options",
"subtitle": "Choose what to restore",
"types": {
"restoreTypes": {
"full": {
"name": "Full Restore",
"description": "Restore everything including database, photos, and archives",
@@ -1239,7 +1275,10 @@
"description": "Choose specific items to restore",
"warning": "Only selected items will be restored"
}
},
},
"options": {
"title": "Restore Options",
"subtitle": "Choose what to restore",
"additionalOptions": {
"title": "Additional Options",
"skipPreBackup": "Skip Pre-Restore Backup",
@@ -1302,6 +1341,9 @@
"starting": "Starting...",
"validating": "Validating...",
"startNewRestore": "Start New Restore"
},
"messages": {
"restoreStarted": "Restore started successfully"
}
},
"messages": {
@@ -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: '',
+59 -10
View File
@@ -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 () => {
@@ -320,14 +349,16 @@ export const EventDetailsPage: React.FC = () => {
{t('common.edit')}
</Button>
)}
<Button
variant="outline"
size="sm"
leftIcon={<MessageSquare className="w-4 h-4" />}
onClick={() => navigate(`/admin/events/${id}/feedback`)}
>
{t('feedback.manage', 'Manage Feedback')}
</Button>
{feedbackSettings?.feedback_enabled && (
<Button
variant="outline"
size="sm"
leftIcon={<MessageSquare className="w-4 h-4" />}
onClick={() => navigate(`/admin/events/${id}/feedback`)}
>
{t('feedback.manage', 'Manage Feedback')}
</Button>
)}
</>
)}
{event.share_link && (
@@ -518,6 +549,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 +828,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">
@@ -392,7 +392,7 @@ export const EventFeedbackPage: React.FC = () => {
<div className="flex items-center gap-3 mb-2">
<Star className="w-8 h-8 text-yellow-500" />
<div>
<p className="text-2xl font-bold">{analytics.summary.average_rating.toFixed(1)}</p>
<p className="text-2xl font-bold">{(analytics.summary.average_rating || 0).toFixed(1)}</p>
<p className="text-sm text-neutral-600">{t('feedback.avgRating', 'Average Rating')}</p>
</div>
</div>
+18 -9
View File
@@ -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;