feat: draft mode, admin branding, and workflow improvements

Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table

Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token

Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)

OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
  branding settings

Editable Client Email:
- Customer email is now editable after event creation in edit mode

Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
  from global branding configuration

Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
This commit is contained in:
Paul Nothaft
2026-04-08 11:42:38 +02:00
parent 125cd0d003
commit 40332a71db
19 changed files with 456 additions and 62 deletions
+26 -3
View File
@@ -13,6 +13,7 @@ import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
import { buildResourceUrl, getApiBaseUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -30,6 +31,24 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
// Fetch branding settings
const { data: brandingSettings } = useQuery({
queryKey: ['admin-settings', 'branding'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) return response.json();
return null;
},
staleTime: 5 * 60 * 1000,
});
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -82,10 +101,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* PicPeak logo - sticky to the left on all sizes */}
{/* Logo - sticky to the left on all sizes */}
<div className="flex items-center gap-2">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
{/* Date display - hidden on smaller screens */}
@@ -40,7 +40,7 @@ export const DynamicFavicon: React.FC = () => {
}
}, [settings?.branding_favicon_url]);
// Update document title when company name or tagline changes
// Update document title and OG meta tags when company name or tagline changes
useEffect(() => {
const companyName = settings?.branding_company_name?.trim();
const tagline = settings?.branding_company_tagline?.trim();
@@ -52,6 +52,33 @@ export const DynamicFavicon: React.FC = () => {
} else {
document.title = DEFAULT_TITLE;
}
// Update OG meta tags
const title = companyName || 'PicPeak';
const description = tagline || 'Photo Sharing Platform';
const updateMeta = (property: string, content: string) => {
let meta = document.querySelector(`meta[property="${property}"]`) as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('property', property);
document.head.appendChild(meta);
}
meta.content = content;
};
updateMeta('og:title', document.title);
updateMeta('og:site_name', title);
updateMeta('og:description', description);
// Also update standard meta description
let metaDesc = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = description;
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
+5
View File
@@ -1008,6 +1008,11 @@
"adminNotificationEmail": "Admin-Benachrichtigungs-E-Mail",
"inactive": "Inaktiv",
"expired": "Abgelaufen",
"draft": "Entwurf",
"publishAndNotify": "Veröffentlichen & Kunden benachrichtigen",
"publishConfirm": "Dadurch wird die Galerie zugänglich und die Benachrichtigungs-E-Mail an den Kunden gesendet. Fortfahren?",
"publishSuccess": "Galerie veröffentlicht und Kunde benachrichtigt!",
"draftBanner": "Diese Galerie befindet sich im Entwurfsmodus. Laden Sie Ihre Fotos hoch und veröffentlichen Sie, wenn Sie bereit sind.",
"daysLeft": "{{count}} Tag verbleibend",
"daysLeft_plural": "{{count}} Tage verbleibend",
"subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen",
+5
View File
@@ -530,6 +530,11 @@
"days365": "1 year",
"inactive": "Inactive",
"expired": "Expired",
"draft": "Draft",
"publishAndNotify": "Publish & Notify Client",
"publishConfirm": "This will make the gallery accessible and send the notification email to the client. Continue?",
"publishSuccess": "Gallery published and client notified!",
"draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.",
"daysLeft": "({{count}} day left)",
"daysLeft_plural": "({{count}} days left)",
"subtitle": "Manage your photo galleries and events",
+5
View File
@@ -530,6 +530,11 @@
"days365": "1 jaar",
"inactive": "Inactief",
"expired": "Verlopen",
"draft": "Concept",
"publishAndNotify": "Publiceren & klant informeren",
"publishConfirm": "Hiermee wordt de galerij toegankelijk en wordt de notificatie-e-mail naar de klant verzonden. Doorgaan?",
"publishSuccess": "Galerij gepubliceerd en klant ge\u00efnformeerd!",
"draftBanner": "Deze galerij staat in conceptmodus. Upload je foto's en publiceer wanneer je klaar bent.",
"daysLeft": "{{count}}d resterend",
"daysLeft_plural": "{{count}}d resterend",
"subtitle": "Beheer uw fotogalerijen en evenementen",
+5
View File
@@ -530,6 +530,11 @@
"days365": "1 ano",
"inactive": "Inativo",
"expired": "Expirado",
"draft": "Rascunho",
"publishAndNotify": "Publicar e notificar cliente",
"publishConfirm": "Isso tornará a galeria acessível e enviará o e-mail de notificação ao cliente. Continuar?",
"publishSuccess": "Galeria publicada e cliente notificado!",
"draftBanner": "Esta galeria está em modo rascunho. Envie suas fotos e publique quando estiver pronto.",
"daysLeft": "({{count}} dia restante)",
"daysLeft_plural": "({{count}} dias restantes)",
"subtitle": "Gerencie suas galerias de fotos e eventos",
+5
View File
@@ -530,6 +530,11 @@
"days365": "1 год",
"inactive": "Неактивный",
"expired": "Истёк",
"draft": "Черновик",
"publishAndNotify": "Опубликовать и уведомить клиента",
"publishConfirm": "Галерея станет доступной, и клиенту будет отправлено уведомление по электронной почте. Продолжить?",
"publishSuccess": "Галерея опубликована, клиент уведомлён!",
"draftBanner": "Эта галерея находится в режиме черновика. Загрузите фотографии, затем опубликуйте, когда будете готовы.",
"daysLeft": "(осталось {{count}} день)",
"daysLeft_plural": "(осталось {{count}} дней)",
"subtitle": "Управляйте своими фотогалереями и событиями",
+11 -5
View File
@@ -25,7 +25,7 @@ export const AdminLoginPage: React.FC = () => {
const [loginSuccess, setLoginSuccess] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch branding settings
// Fetch branding settings (unauthenticated)
const { data: settingsData } = useQuery({
queryKey: ['admin-login-settings'],
queryFn: async () => {
@@ -35,6 +35,12 @@ export const AdminLoginPage: React.FC = () => {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : logoUrl)
: '/picpeak-logo-transparent.png';
// Check for session expired message
useEffect(() => {
if (searchParams.get('session') === 'expired') {
@@ -126,13 +132,13 @@ export const AdminLoginPage: React.FC = () => {
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src="/picpeak-logo-transparent.png"
alt="PicPeak"
<img
src={resolvedLogoUrl}
alt={companyName}
className="w-[180px] h-[130px] object-contain"
/>
</div>
+111 -19
View File
@@ -27,7 +27,8 @@ import {
Droplets,
MousePointer,
Layout,
Trash2
Trash2,
Send
} from 'lucide-react';
import { parseISO, differenceInDays, isValid } from 'date-fns';
@@ -154,6 +155,7 @@ export const EventDetailsPage: React.FC = () => {
upload_category_id: number | null;
hero_photo_id: number | null;
customer_name: string;
customer_email: string;
source_mode: 'managed' | 'reference';
external_path: string;
require_password: boolean;
@@ -186,6 +188,7 @@ export const EventDetailsPage: React.FC = () => {
upload_category_id: null,
hero_photo_id: null,
customer_name: '',
customer_email: '',
source_mode: 'managed',
external_path: '',
require_password: true,
@@ -368,6 +371,19 @@ export const EventDetailsPage: React.FC = () => {
},
});
// Publish mutation (Draft mode)
const publishMutation = useMutation({
mutationFn: () => eventsService.publishEvent(parseInt(id!)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('events.publishSuccess'));
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
},
});
// Extend expiration mutation
const extendMutation = useMutation({
mutationFn: (days: number) => {
@@ -405,6 +421,7 @@ export const EventDetailsPage: React.FC = () => {
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
customer_name: event.customer_name || '',
customer_email: event.customer_email || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '',
require_password: normalizeRequirePassword(event.require_password),
@@ -585,6 +602,9 @@ export const EventDetailsPage: React.FC = () => {
if (editForm.customer_name !== undefined && editForm.customer_name !== null) {
updateData.customer_name = editForm.customer_name;
}
if (editForm.customer_email !== undefined && editForm.customer_email !== null && editForm.customer_email.trim()) {
updateData.customer_email = editForm.customer_email;
}
if (editForm.new_password) {
updateData.password = editForm.new_password;
@@ -682,6 +702,11 @@ export const EventDetailsPage: React.FC = () => {
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
{event.is_draft ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300">
{t('events.draft')}
</span>
) : null}
{event.is_archived ? (
<span className="text-neutral-500 dark:text-neutral-400 flex items-center">
<Archive className="w-4 h-4 mr-1" />
@@ -748,7 +773,10 @@ export const EventDetailsPage: React.FC = () => {
)}
{event.share_link && !isEditing && (
<a
href={resolveShareLink(event.share_link)}
href={event.is_draft
? `${resolveShareLink(event.share_link)}${resolveShareLink(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: resolveShareLink(event.share_link)
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
@@ -761,6 +789,36 @@ export const EventDetailsPage: React.FC = () => {
</div>
</div>
{/* Draft Banner */}
{event.is_draft && !event.is_archived && (
<Card className="p-4 mb-6 border-2 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-yellow-600 dark:text-yellow-400" />
<div className="flex-1">
<p className="font-medium text-yellow-900 dark:text-yellow-200">
{t('events.draft')}
</p>
<p className="text-sm mt-1 text-yellow-700 dark:text-yellow-300">
{t('events.draftBanner')}
</p>
</div>
<Button
variant="primary"
size="sm"
leftIcon={<Send className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.publishConfirm'))) {
publishMutation.mutate();
}
}}
isLoading={publishMutation.isPending}
>
{t('events.publishAndNotify')}
</Button>
</div>
</Card>
)}
{/* Expiration Warning */}
{!event.is_archived && (isExpired || isExpiring) && (
<Card className={`p-4 mb-6 border-2 ${isExpired ? 'border-red-500 bg-red-50' : 'border-orange-500 bg-orange-50'}`}>
@@ -874,6 +932,18 @@ export const EventDetailsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.hostEmail')}
</label>
<Input
type="email"
value={editForm.customer_email}
onChange={(e) => setEditForm(prev => ({ ...prev, customer_email: e.target.value }))}
placeholder={t('events.hostEmailPlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')}
@@ -1671,23 +1741,45 @@ export const EventDetailsPage: React.FC = () => {
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2>
<div className="space-y-3">
<Button
variant="outline"
leftIcon={<Archive className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.archiveConfirm'))) {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending}
className="w-full justify-center"
>
{t('events.archiveEvent')}
</Button>
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.archivingInfo')}
</p>
{event.is_draft ? (
<>
<Button
variant="primary"
leftIcon={<Send className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.publishConfirm'))) {
publishMutation.mutate();
}
}}
isLoading={publishMutation.isPending}
className="w-full justify-center"
>
{t('events.publishAndNotify')}
</Button>
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.draftBanner')}
</p>
</>
) : (
<>
<Button
variant="outline"
leftIcon={<Archive className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.archiveConfirm'))) {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending}
className="w-full justify-center"
>
{t('events.archiveEvent')}
</Button>
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.archivingInfo')}
</p>
</>
)}
</div>
</Card>
)}
+14 -3
View File
@@ -48,8 +48,9 @@ export const EventsListPage: React.FC = () => {
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
const isDraftFilter = searchParams.get('filter') === 'draft';
// Close dropdown when clicking outside
useEffect(() => {
@@ -141,8 +142,10 @@ export const EventsListPage: React.FC = () => {
let events = [...data.events];
// Apply status filter
if (statusFilter === 'active') {
events = events.filter(e => e.is_active && !e.is_archived);
if (isDraftFilter) {
events = events.filter(e => e.is_draft);
} else if (statusFilter === 'active') {
events = events.filter(e => e.is_active && !e.is_archived && !e.is_draft);
} else if (isExpiringFilter) {
events = events.filter(e => {
if (!e.is_active || e.is_archived) return false;
@@ -190,6 +193,7 @@ export const EventsListPage: React.FC = () => {
};
const getEventStatus = (event: Event) => {
if (event.is_draft) return { label: t('events.draft'), color: 'text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/40' };
if (event.is_archived) return { label: t('events.archived'), color: 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-700' };
if (!event.is_active) return { label: t('events.inactive'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' };
@@ -339,6 +343,13 @@ export const EventsListPage: React.FC = () => {
>
{t('events.expiring')}
</Button>
<Button
variant={isDraftFilter ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'draft' })}
>
{t('events.draft')}
</Button>
<Button
variant={statusFilter === 'archived' ? 'primary' : 'outline'}
size="md"
+13 -1
View File
@@ -74,7 +74,7 @@ export const eventsService = {
async getEvents(
page: number = 1,
limit: number = 20,
status?: 'active' | 'inactive' | 'archived'
status?: 'active' | 'inactive' | 'archived' | 'draft'
): Promise<EventsListResponse> {
const params = new URLSearchParams({
page: page.toString(),
@@ -173,6 +173,18 @@ export const eventsService = {
return response.data;
},
// Publish a draft event
async publishEvent(eventId: number): Promise<{ message: string; is_draft: boolean }> {
const response = await api.post(`/admin/events/${eventId}/publish`);
return response.data;
},
// Get admin preview token (uses existing admin session token)
getPreviewToken(): string | null {
const token = sessionStorage.getItem('admin_token') || localStorage.getItem('admin_token');
return token;
},
// Rename event
async renameEvent(eventId: number, newEventName: string, resendEmail: boolean = false): Promise<{
success: boolean;
+2
View File
@@ -55,6 +55,8 @@ export interface Event {
css_template_id?: number | null;
// Photo cap
photo_cap?: number | null;
// Draft mode
is_draft?: boolean;
// Client access (#172)
client_access_enabled?: boolean;
client_share_token?: string;