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:
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -530,6 +530,11 @@
|
||||
"days365": "1 год",
|
||||
"inactive": "Неактивный",
|
||||
"expired": "Истёк",
|
||||
"draft": "Черновик",
|
||||
"publishAndNotify": "Опубликовать и уведомить клиента",
|
||||
"publishConfirm": "Галерея станет доступной, и клиенту будет отправлено уведомление по электронной почте. Продолжить?",
|
||||
"publishSuccess": "Галерея опубликована, клиент уведомлён!",
|
||||
"draftBanner": "Эта галерея находится в режиме черновика. Загрузите фотографии, затем опубликуйте, когда будете готовы.",
|
||||
"daysLeft": "(осталось {{count}} день)",
|
||||
"daysLeft_plural": "(осталось {{count}} дней)",
|
||||
"subtitle": "Управляйте своими фотогалереями и событиями",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user