From 40332a71db6534097940d3f9362b0fe651dba6c7 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 8 Apr 2026 11:42:38 +0200 Subject: [PATCH] feat: draft mode, admin branding, and workflow improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/076_add_is_draft_column.js | 21 +++ backend/src/middleware/gallery.js | 56 +++++--- backend/src/routes/adminEvents.js | 129 ++++++++++++++++- backend/src/routes/gallery.js | 14 +- backend/src/services/emailProcessor.js | 3 +- backend/src/services/shareLinkService.js | 6 +- backend/src/utils/frontendUrl.js | 27 ++++ frontend/src/components/admin/AdminHeader.tsx | 29 +++- .../src/components/common/DynamicFavicon.tsx | 29 +++- frontend/src/i18n/locales/de.json | 5 + frontend/src/i18n/locales/en.json | 5 + frontend/src/i18n/locales/nl.json | 5 + frontend/src/i18n/locales/pt.json | 5 + frontend/src/i18n/locales/ru.json | 5 + frontend/src/pages/admin/AdminLoginPage.tsx | 16 ++- frontend/src/pages/admin/EventDetailsPage.tsx | 130 +++++++++++++++--- frontend/src/pages/admin/EventsListPage.tsx | 17 ++- frontend/src/services/events.service.ts | 14 +- frontend/src/types/index.ts | 2 + 19 files changed, 456 insertions(+), 62 deletions(-) create mode 100644 backend/migrations/core/076_add_is_draft_column.js create mode 100644 backend/src/utils/frontendUrl.js diff --git a/backend/migrations/core/076_add_is_draft_column.js b/backend/migrations/core/076_add_is_draft_column.js new file mode 100644 index 00000000..a7581652 --- /dev/null +++ b/backend/migrations/core/076_add_is_draft_column.js @@ -0,0 +1,21 @@ +/** + * Migration to add is_draft column to events table. + * Draft events are not visible to gallery visitors until published. + */ +exports.up = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'is_draft'); + if (!hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.boolean('is_draft').defaultTo(false); + }); + } +}; + +exports.down = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'is_draft'); + if (hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('is_draft'); + }); + } +}; diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index fb9ab8ad..03f01a75 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -4,6 +4,18 @@ const { formatBoolean } = require('../utils/dbCompat'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const logger = require('../utils/logger'); +// Check if the request carries a valid admin preview token (Feature 3) +function isAdminPreview(req) { + const previewToken = req.query?.preview; + if (!previewToken) return false; + try { + const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' }); + return decoded.type === 'admin'; + } catch { + return false; + } +} + // Middleware to verify gallery access async function verifyGalleryAccess(req, res, next) { try { @@ -16,15 +28,18 @@ async function verifyGalleryAccess(req, res, next) { return res.status(401).json({ error: 'No token provided' }); } + const adminPreview = isAdminPreview(req); event = await withRetry(async () => { - return await db('events') - .where({ + const q = db('events') + .where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) - }) - .select('*') - .first(); + }); + if (!adminPreview) { + q.where({ is_draft: formatBoolean(false) }); + } + return await q.select('*').first(); }); if (!event) { @@ -66,15 +81,18 @@ async function verifyGalleryAccess(req, res, next) { // If we have a slug in the URL params or from pre-middleware, verify it matches if (requestedSlug) { // Verify by slug and ensure it matches the token's event + const adminPreviewToken = isAdminPreview(req); event = await withRetry(async () => { - return await db('events') - .where({ + const q = db('events') + .where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) - }) - .select('*') - .first(); + }); + if (!adminPreviewToken) { + q.where({ is_draft: formatBoolean(false) }); + } + return await q.select('*').first(); }); // Verify the token's eventId matches @@ -83,15 +101,18 @@ async function verifyGalleryAccess(req, res, next) { } } else { // Fallback to using eventId from token + const adminPreviewFallback = isAdminPreview(req); event = await withRetry(async () => { - return await db('events') - .where({ - id: decoded.eventId, + const q = db('events') + .where({ + id: decoded.eventId, is_active: formatBoolean(true), is_archived: formatBoolean(false) - }) - .select('*') - .first(); + }); + if (!adminPreviewFallback) { + q.where({ is_draft: formatBoolean(false) }); + } + return await q.select('*').first(); }); } @@ -122,5 +143,6 @@ async function verifyGalleryAccess(req, res, next) { } module.exports = { - verifyGalleryAccess + verifyGalleryAccess, + isAdminPreview }; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 24d49650..c76f538e 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -21,6 +21,7 @@ const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); const eventTypeService = require('../services/eventTypeService'); const { validateFileType } = require('../utils/fileSecurityUtils'); const { requireEventOwnership } = require('../middleware/ownership'); +const { getFrontendBaseUrl } = require('../utils/frontendUrl'); // Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point const validateHeroImageAnchor = (value) => { @@ -111,6 +112,50 @@ const getEventFieldRequirements = async () => { } }; +// Helper to get branding defaults for new events (Feature 7: Branding Inheritance) +const getBrandingDefaults = async () => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'branding_logo_display_hero', + 'branding_logo_size', + 'branding_logo_position' + ]) + .select('setting_key', 'setting_value'); + + const defaults = { + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top' + }; + + settings.forEach(s => { + let value = s.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch (e) { /* use as-is */ } + } + if (s.setting_key === 'branding_logo_display_hero') { + defaults.hero_logo_visible = value !== false; + } + if (s.setting_key === 'branding_logo_size' && value) { + defaults.hero_logo_size = value; + } + if (s.setting_key === 'branding_logo_position' && value) { + defaults.hero_logo_position = value; + } + }); + + return defaults; + } catch (error) { + logger.error('Failed to get branding defaults', { error: error.message }); + return { + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top' + }; + } +}; + // Use parseStringInput from shared parsers for customer data extraction const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); @@ -267,7 +312,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ photo_cap = null, // Client access settings (#172) client_access_enabled = false, - client_password = null + client_password = null, + // Draft mode + is_draft = true } = req.body; const customerName = getCustomerNameFromPayload(req.body); @@ -396,6 +443,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [ } } + // Get branding defaults for hero logo settings (Feature 7: Branding Inheritance) + const brandingDefaults = await getBrandingDefaults(); + const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible; + const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; + const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; + // Insert into database const insertResult = await db('events').insert({ slug, @@ -422,13 +475,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [ watermark_text, require_password: formatBoolean(requirePassword), css_template_id: css_template_id || null, - hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true), - hero_logo_size: hero_logo_size || 'medium', - hero_logo_position: hero_logo_position || 'top', + hero_logo_visible: formatBoolean(effectiveHeroLogoVisible), + hero_logo_size: effectiveHeroLogoSize, + hero_logo_position: effectiveHeroLogoPosition, header_style: effectiveHeaderStyle || 'standard', hero_divider_style: effectiveDividerStyle || 'wave', hero_image_anchor: hero_image_anchor || 'center', photo_cap: photo_cap || null, + is_draft: formatBoolean(parseBooleanInput(is_draft, true)), // Client access (#172) client_access_enabled: formatBoolean(client_access_enabled), ...(client_access_enabled && client_password ? { @@ -464,10 +518,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ { type: 'admin', id: req.admin.id, name: req.admin.username } ); - // Queue creation email (only if there is a recipient) + // Queue creation email (only if there is a recipient and event is not a draft) // Language detection is handled by email processor + const isDraft = parseBooleanInput(is_draft, true); - if (customerEmail) { + if (customerEmail && !isDraft) { // Build email data with optional client access info const emailData = { customer_name: customerName, @@ -509,6 +564,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ customer_email: customerEmail, require_password: requirePassword, photo_cap: photo_cap || null, + is_draft: isDraft, share_link: shareUrl, expires_at: expires_at ? expires_at.toISOString() : null, created_at: new Date().toISOString() @@ -556,6 +612,8 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => query = query.where('is_archived', formatBoolean(true)); } else if (status === 'inactive') { query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false)); + } else if (status === 'draft') { + query = query.where('is_draft', formatBoolean(true)); } else if (status === 'expiring') { const sevenDaysFromNow = new Date(); sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); @@ -680,6 +738,65 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) } }); +// Publish a draft event (set is_draft=false and queue creation email) +router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + const event = await db('events').where('id', id).first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (!parseBooleanInput(event.is_draft, false)) { + return res.status(400).json({ error: 'Event is already published' }); + } + + // Set is_draft to false + await db('events').where('id', id).update({ is_draft: formatBoolean(false) }); + + // Queue creation email + const customerEmail = event.customer_email || event.host_email; + const customerName = event.customer_name || event.host_name; + if (customerEmail) { + const frontendBase = await getFrontendBaseUrl(); + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + + const emailData = { + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), + event_name: event.event_name, + event_date: event.event_date, + gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, + gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required', + expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, + welcome_message: event.welcome_message || '' + }; + + await db('email_queue').insert({ + event_id: id, + recipient_email: customerEmail, + email_type: 'gallery_created', + email_data: JSON.stringify(emailData), + status: 'pending', + created_at: new Date() + }); + } + + await logActivity('event_published', + { event_name: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event published successfully', is_draft: false }); + } catch (error) { + logger.error('Error publishing event:', { error: error.message }); + res.status(500).json({ error: 'Failed to publish event' }); + } +}); + // Update event router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ body('event_name').optional().trim().notEmpty(), diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index cc41c082..3072d370 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -6,7 +6,7 @@ const path = require('path'); const router = express.Router(); const watermarkService = require('../services/watermarkService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); -const { verifyGalleryAccess } = require('../middleware/gallery'); +const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); @@ -74,7 +74,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => { const { slug, token } = req.params; const event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) }) .select('id', 'share_link', 'share_token') .first(); @@ -122,7 +122,8 @@ router.get('/:slug/info', async (req, res) => { 'hero_logo_url', 'header_style', 'hero_divider_style', - 'hero_image_anchor' + 'hero_image_anchor', + 'is_draft' ) .first(); @@ -138,11 +139,16 @@ router.get('/:slug/info', async (req, res) => { } return res.status(404).json({ error: 'Gallery not found' }); } - + // Check if event is archived if (event.is_archived) { return res.status(404).json({ error: 'Gallery has been archived and is no longer available' }); } + + // Check if event is a draft (allow admin preview) + if (event.is_draft && !isAdminPreview(req)) { + return res.status(404).json({ error: 'Gallery is not yet published' }); + } // If token provided, verify it matches the share link if (token) { diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index ed32365e..0eaea247 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -2,6 +2,7 @@ const nodemailer = require('nodemailer'); const Handlebars = require('handlebars'); const { db } = require('../database/db'); const logger = require('../utils/logger'); +const { getFrontendBaseUrl } = require('../utils/frontendUrl'); let transporter = null; let lastConfigHash = null; @@ -161,7 +162,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') { const hoverColor = darkenColor(primaryColor, 0.15); // Build full logo URL - ensure logoUrl is a valid non-empty string - const frontendUrl = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, ''); + const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; const logoPath = (typeof logoUrl === 'string' && logoUrl.trim()) ? logoUrl : '/picpeak-logo-transparent.png'; const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`; logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl }); diff --git a/backend/src/services/shareLinkService.js b/backend/src/services/shareLinkService.js index e8e1fb7b..8bcfe1f2 100644 --- a/backend/src/services/shareLinkService.js +++ b/backend/src/services/shareLinkService.js @@ -1,6 +1,7 @@ const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils'); +const { getFrontendBaseUrl } = require('../utils/frontendUrl'); const SETTING_KEY = 'general_short_gallery_urls'; const CACHE_TTL_MS = 60_000; @@ -87,7 +88,7 @@ const buildShareLinkVariants = async ({ slug, shareToken }) => { const shortEnabled = await isShortGalleryUrlsEnabled(); const sharePath = buildSharePath(slug, shareToken, shortEnabled); - const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); + const frontendBase = await getFrontendBaseUrl(); const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath; return { @@ -112,7 +113,8 @@ const getEventShareToken = (event) => { const ACTIVE_EVENT_FILTER = { is_active: formatBoolean(true), - is_archived: formatBoolean(false) + is_archived: formatBoolean(false), + is_draft: formatBoolean(false) }; const resolveShareIdentifier = async (identifier) => { diff --git a/backend/src/utils/frontendUrl.js b/backend/src/utils/frontendUrl.js new file mode 100644 index 00000000..19edd33b --- /dev/null +++ b/backend/src/utils/frontendUrl.js @@ -0,0 +1,27 @@ +const { db } = require('../database/db'); + +const getFrontendBaseUrl = async () => { + let base = (process.env.FRONTEND_URL || '').trim().replace(/\/$/, ''); + if (base) return base; + + try { + const setting = await db('app_settings') + .where('setting_key', 'general_site_url') + .select('setting_value') + .first(); + + if (setting && setting.setting_value) { + let val = setting.setting_value; + if (typeof val === 'string') { + try { val = JSON.parse(val); } catch (_) {} + } + if (typeof val === 'string' && val.trim()) { + base = val.trim().replace(/\/$/, ''); + } + } + } catch (_) {} + + return base; +}; + +module.exports = { getFrontendBaseUrl }; diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 275e04d5..0aff97e8 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -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 = ({ 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(null); const notificationRef = useRef(null); @@ -82,10 +101,14 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { - {/* PicPeak logo - sticky to the left on all sizes */} + {/* Logo - sticky to the left on all sizes */}
- PicPeak - PicPeak + {(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( + {companyName} + )} + {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( + {companyName} + )}
{/* Date display - hidden on smaller screens */} diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx index eff7e9ed..ac4ebfaf 100644 --- a/frontend/src/components/common/DynamicFavicon.tsx +++ b/frontend/src/components/common/DynamicFavicon.tsx @@ -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; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 236bb971..762d04ea 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 30ae0f9e..bb17c36c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 50c53ac8..69651d15 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -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", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index f7ec8e9c..6a807056 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 01ee14fc..76f06c0c 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -530,6 +530,11 @@ "days365": "1 год", "inactive": "Неактивный", "expired": "Истёк", + "draft": "Черновик", + "publishAndNotify": "Опубликовать и уведомить клиента", + "publishConfirm": "Галерея станет доступной, и клиенту будет отправлено уведомление по электронной почте. Продолжить?", + "publishSuccess": "Галерея опубликована, клиент уведомлён!", + "draftBanner": "Эта галерея находится в режиме черновика. Загрузите фотографии, затем опубликуйте, когда будете готовы.", "daysLeft": "(осталось {{count}} день)", "daysLeft_plural": "(осталось {{count}} дней)", "subtitle": "Управляйте своими фотогалереями и событиями", diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 5c512b02..0415a2da 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -25,7 +25,7 @@ export const AdminLoginPage: React.FC = () => { const [loginSuccess, setLoginSuccess] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(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 = () => {
{/* Logo/Header */}
-
- PicPeak
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index dc418313..488d9110 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -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')} + {event.is_draft ? ( + + {t('events.draft')} + + ) : null} {event.is_archived ? ( @@ -748,7 +773,10 @@ export const EventDetailsPage: React.FC = () => { )} {event.share_link && !isEditing && ( {
+ {/* Draft Banner */} + {event.is_draft && !event.is_archived && ( + +
+ +
+

+ {t('events.draft')} +

+

+ {t('events.draftBanner')} +

+
+ +
+
+ )} + {/* Expiration Warning */} {!event.is_archived && (isExpired || isExpiring) && ( @@ -874,6 +932,18 @@ export const EventDetailsPage: React.FC = () => { /> +
+ + setEditForm(prev => ({ ...prev, customer_email: e.target.value }))} + placeholder={t('events.hostEmailPlaceholder')} + /> +
+