Merge pull request #247 from the-luap/feat/photo-visibility-client-access
feat: photo visibility control with client access (#172)
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers');
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// Add visibility column to photos table
|
||||||
|
await addColumnIfNotExists(knex, 'photos', 'visibility', (table) => {
|
||||||
|
table.string('visibility', 20).defaultTo('visible').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add client access columns to events table
|
||||||
|
await addColumnIfNotExists(knex, 'events', 'client_access_enabled', (table) => {
|
||||||
|
table.boolean('client_access_enabled').defaultTo(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await addColumnIfNotExists(knex, 'events', 'client_password_hash', (table) => {
|
||||||
|
table.string('client_password_hash', 255).nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await addColumnIfNotExists(knex, 'events', 'client_share_token', (table) => {
|
||||||
|
table.string('client_share_token', 64).nullable().unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Index for filtering photos by visibility
|
||||||
|
await createIndexIfNotExists(knex, 'photos', ['event_id', 'visibility'], 'idx_photos_event_visibility');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// Safe rollback - intentionally no-op to avoid data loss
|
||||||
|
};
|
||||||
@@ -102,8 +102,9 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
|
|
||||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||||
req.event = event;
|
req.event = event;
|
||||||
|
req.accessLevel = decoded.accessLevel || 'guest';
|
||||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||||
|
|
||||||
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
||||||
req.clientInfo = {
|
req.clientInfo = {
|
||||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ const mapEventForApi = (event) => {
|
|||||||
host_email,
|
host_email,
|
||||||
customer_name,
|
customer_name,
|
||||||
customer_email,
|
customer_email,
|
||||||
|
password_hash: _ph,
|
||||||
|
client_password_hash: _cph,
|
||||||
...rest
|
...rest
|
||||||
} = event;
|
} = event;
|
||||||
|
|
||||||
@@ -208,7 +210,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||||
|
// Client access settings (#172)
|
||||||
|
body('client_access_enabled').optional().isBoolean(),
|
||||||
|
body('client_password').optional().isString()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Create event request body', { body: req.body });
|
logger.debug('Create event request body', { body: req.body });
|
||||||
@@ -258,7 +263,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
// Hero image anchor position (#162)
|
// Hero image anchor position (#162)
|
||||||
hero_image_anchor = 'center',
|
hero_image_anchor = 'center',
|
||||||
// Photo cap
|
// Photo cap
|
||||||
photo_cap = null
|
photo_cap = null,
|
||||||
|
// Client access settings (#172)
|
||||||
|
client_access_enabled = false,
|
||||||
|
client_password = null
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const customerName = getCustomerNameFromPayload(req.body);
|
const customerName = getCustomerNameFromPayload(req.body);
|
||||||
@@ -419,7 +427,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
header_style: effectiveHeaderStyle || 'standard',
|
header_style: effectiveHeaderStyle || 'standard',
|
||||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||||
hero_image_anchor: hero_image_anchor || 'center',
|
hero_image_anchor: hero_image_anchor || 'center',
|
||||||
photo_cap: photo_cap || null
|
photo_cap: photo_cap || null,
|
||||||
|
// Client access (#172)
|
||||||
|
client_access_enabled: formatBoolean(client_access_enabled),
|
||||||
|
...(client_access_enabled && client_password ? {
|
||||||
|
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
|
||||||
|
client_share_token: crypto.randomBytes(32).toString('hex')
|
||||||
|
} : {})
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -453,21 +467,32 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
// Language detection is handled by email processor
|
// Language detection is handled by email processor
|
||||||
|
|
||||||
if (customerEmail) {
|
if (customerEmail) {
|
||||||
|
// Build email data with optional client access info
|
||||||
|
const emailData = {
|
||||||
|
customer_name: customerName,
|
||||||
|
customer_email: customerEmail,
|
||||||
|
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||||
|
event_name,
|
||||||
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
|
gallery_link: shareUrl,
|
||||||
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
|
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||||
|
welcome_message: welcome_message || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include client access info in email when enabled (#172)
|
||||||
|
if (client_access_enabled && client_password) {
|
||||||
|
const createdEvent = await db('events').where('id', eventId).first();
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
|
||||||
|
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
|
||||||
|
emailData.client_password = client_password;
|
||||||
|
}
|
||||||
|
|
||||||
await db('email_queue').insert({
|
await db('email_queue').insert({
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
recipient_email: customerEmail,
|
recipient_email: customerEmail,
|
||||||
email_type: 'gallery_created',
|
email_type: 'gallery_created',
|
||||||
email_data: JSON.stringify({
|
email_data: JSON.stringify(emailData),
|
||||||
customer_name: customerName,
|
|
||||||
customer_email: customerEmail,
|
|
||||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
|
||||||
event_name,
|
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
|
||||||
gallery_link: shareUrl,
|
|
||||||
gallery_password: requirePassword ? password : 'No password required',
|
|
||||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
|
||||||
welcome_message: welcome_message || ''
|
|
||||||
}),
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
created_at: new Date()
|
created_at: new Date()
|
||||||
// scheduled_at will use default value
|
// scheduled_at will use default value
|
||||||
@@ -709,7 +734,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
|||||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
|
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||||
|
// Client access settings (#172)
|
||||||
|
body('client_access_enabled').optional().isBoolean(),
|
||||||
|
body('client_password').optional().isString(),
|
||||||
|
body('regenerate_client_token').optional().isBoolean()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -788,6 +817,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
|||||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle client access fields (#172)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
|
||||||
|
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
|
||||||
|
// Auto-generate client share token when first enabling
|
||||||
|
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
|
||||||
|
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
|
||||||
|
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
|
||||||
|
delete updates.client_password;
|
||||||
|
} else {
|
||||||
|
delete updates.client_password;
|
||||||
|
}
|
||||||
|
if (updates.regenerate_client_token) {
|
||||||
|
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
delete updates.regenerate_client_token;
|
||||||
|
|
||||||
// Log the update request for debugging
|
// Log the update request for debugging
|
||||||
logger.debug('Update event request', {
|
logger.debug('Update event request', {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
|||||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, photoId } = req.params;
|
const { eventId, photoId } = req.params;
|
||||||
const { category_id } = req.body;
|
const { category_id, visibility } = req.body;
|
||||||
|
|
||||||
// Verify photo belongs to event
|
// Verify photo belongs to event
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
@@ -601,6 +601,13 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
|||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
|
|
||||||
|
// Handle visibility update (#172)
|
||||||
|
if (visibility !== undefined) {
|
||||||
|
if (['visible', 'hidden'].includes(visibility)) {
|
||||||
|
updateData.visibility = visibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle type-based categories ('individual' or 'collage')
|
// Handle type-based categories ('individual' or 'collage')
|
||||||
// These are string values that map to the photo.type field
|
// These are string values that map to the photo.type field
|
||||||
if (category_id === 'individual' || category_id === 'collage') {
|
if (category_id === 'individual' || category_id === 'collage') {
|
||||||
@@ -736,6 +743,13 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
|||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
|
|
||||||
|
// Handle visibility update (#172)
|
||||||
|
if (updates.visibility !== undefined) {
|
||||||
|
if (['visible', 'hidden'].includes(updates.visibility)) {
|
||||||
|
updateData.visibility = updates.visibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.category_id !== undefined) {
|
if (updates.category_id !== undefined) {
|
||||||
// Handle type-based categories ('individual' or 'collage')
|
// Handle type-based categories ('individual' or 'collage')
|
||||||
// These are string values that map to the photo.type field
|
// These are string values that map to the photo.type field
|
||||||
|
|||||||
@@ -290,6 +290,82 @@ router.post('/gallery/verify', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client access login (PIN-based)
|
||||||
|
router.post('/gallery/:slug/client-login', [
|
||||||
|
body('password').notEmpty().isString()
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { password } = req.body;
|
||||||
|
const ipAddress = getClientIp(req);
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!event || !event.client_access_enabled || !event.client_password_hash) {
|
||||||
|
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
|
||||||
|
if (lockoutStatus.isLocked) {
|
||||||
|
return res.status(423).json({
|
||||||
|
error: 'Too many failed attempts. Please try again later.',
|
||||||
|
retryAfter: lockoutStatus.remainingTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, event.client_password_hash);
|
||||||
|
if (!validPassword) {
|
||||||
|
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
|
||||||
|
|
||||||
|
const token = jwt.sign({
|
||||||
|
eventId: event.id,
|
||||||
|
eventSlug: event.slug,
|
||||||
|
type: 'gallery',
|
||||||
|
accessLevel: 'client',
|
||||||
|
ip: ipAddress,
|
||||||
|
loginTime: Date.now()
|
||||||
|
}, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '24h',
|
||||||
|
issuer: 'picpeak-auth'
|
||||||
|
});
|
||||||
|
|
||||||
|
setGalleryAuthCookies(res, token, event.slug);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
event_name: event.event_name,
|
||||||
|
event_type: event.event_type,
|
||||||
|
event_date: event.event_date,
|
||||||
|
welcome_message: event.welcome_message,
|
||||||
|
color_theme: event.color_theme,
|
||||||
|
expires_at: event.expires_at,
|
||||||
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: true
|
||||||
|
},
|
||||||
|
accessLevel: 'client'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Client login error:', error);
|
||||||
|
res.status(500).json({ error: 'Authentication failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Share link authentication (token-based)
|
// Share link authentication (token-based)
|
||||||
router.post('/gallery/share-login', [
|
router.post('/gallery/share-login', [
|
||||||
body('slug').notEmpty().trim(),
|
body('slug').notEmpty().trim(),
|
||||||
|
|||||||
@@ -198,10 +198,18 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
|
|
||||||
// Build the query with sorting
|
// Build the query with sorting
|
||||||
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||||
|
const isClient = req.accessLevel === 'client';
|
||||||
let photosQuery = db('photos')
|
let photosQuery = db('photos')
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.select('photos.*');
|
.select('photos.*');
|
||||||
|
|
||||||
|
// Guests only see visible photos; clients see all
|
||||||
|
if (!isClient) {
|
||||||
|
photosQuery = photosQuery.where(function() {
|
||||||
|
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply sort option
|
// Apply sort option
|
||||||
if (sort === 'capture_date') {
|
if (sort === 'capture_date') {
|
||||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||||
@@ -414,12 +422,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
height: photo.height || null,
|
height: photo.height || null,
|
||||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||||
requires_token: !useJwtUrl,
|
requires_token: !useJwtUrl,
|
||||||
|
// EXIF capture date
|
||||||
|
captured_at: photo.captured_at || null,
|
||||||
|
// Media type
|
||||||
|
media_type: photo.media_type || null,
|
||||||
|
mime_type: photo.mime_type || null,
|
||||||
|
duration: photo.duration || null,
|
||||||
// Feedback data
|
// Feedback data
|
||||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||||
average_rating: photo.average_rating || 0,
|
average_rating: photo.average_rating || 0,
|
||||||
comment_count: commentMap[photo.id] || 0,
|
comment_count: commentMap[photo.id] || 0,
|
||||||
like_count: photo.like_count || 0,
|
like_count: photo.like_count || 0,
|
||||||
favorite_count: photo.favorite_count || 0
|
favorite_count: photo.favorite_count || 0,
|
||||||
|
// Visibility (only included for clients)
|
||||||
|
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -429,24 +445,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Toggle photo visibility (client-only)
|
||||||
|
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Client access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { photoId } = req.params;
|
||||||
|
const { visibility } = req.body;
|
||||||
|
|
||||||
|
if (!['visible', 'hidden'].includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.update({ visibility });
|
||||||
|
|
||||||
|
res.json({ message: 'Photo visibility updated', visibility });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating photo visibility:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk toggle photo visibility (client-only)
|
||||||
|
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Client access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { photoIds, visibility } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['visible', 'hidden'].includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await db('photos')
|
||||||
|
.whereIn('id', photoIds)
|
||||||
|
.where('event_id', req.event.id)
|
||||||
|
.update({ visibility });
|
||||||
|
|
||||||
|
res.json({ message: `${count} photos updated`, visibility });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error bulk updating photo visibility:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
|
|
||||||
// Check if downloads are allowed for this event
|
// Check if downloads are allowed for this event
|
||||||
if (req.event.allow_downloads === false) {
|
if (req.event.allow_downloads === false) {
|
||||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
.where({ id: photoId, event_id: req.event.id })
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!photo) {
|
if (!photo) {
|
||||||
return res.status(404).json({ error: 'Photo not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
// Update download count
|
// Update download count
|
||||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||||
|
|
||||||
@@ -745,11 +828,15 @@ router.get('/:slug/photo/:photoId',
|
|||||||
.where({ id: photoId, event_id: req.event.id })
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
|
||||||
if (!photo) {
|
if (!photo) {
|
||||||
return res.status(404).json({ error: 'Photo not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a video
|
// Check if this is a video
|
||||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
|
|
||||||
@@ -935,6 +1022,11 @@ router.get('/:slug/thumbnail/:photoId',
|
|||||||
return res.status(404).json({ error: 'Photo not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||||
const thumbnailPath = await ensureThumbnail(photo);
|
const thumbnailPath = await ensureThumbnail(photo);
|
||||||
|
|
||||||
@@ -1013,6 +1105,11 @@ router.get('/:slug/hero/:photoId',
|
|||||||
return res.status(404).json({ error: 'Photo not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a video - videos don't get hero images
|
// Check if this is a video - videos don't get hero images
|
||||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
|||||||
@@ -353,6 +353,53 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
htmlBody = htmlTemplate(processedVariables);
|
htmlBody = htmlTemplate(processedVariables);
|
||||||
textBody = textTemplate(processedVariables);
|
textBody = textTemplate(processedVariables);
|
||||||
|
|
||||||
|
// Inject client access section if client_link is provided (#172)
|
||||||
|
if (processedVariables.client_link) {
|
||||||
|
const clientAccessI18n = {
|
||||||
|
de: {
|
||||||
|
label: 'Kundenzugang (Privat)',
|
||||||
|
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
|
||||||
|
link: 'Kundenzugang öffnen',
|
||||||
|
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
label: 'Доступ клиента (Личный)',
|
||||||
|
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
|
||||||
|
link: 'Открыть доступ клиента',
|
||||||
|
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
label: 'Acesso do Cliente (Privado)',
|
||||||
|
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||||
|
link: 'Abrir Acesso do Cliente',
|
||||||
|
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
label: 'Client Access (Private)',
|
||||||
|
desc: 'Review and manage photo visibility before sharing with guests:',
|
||||||
|
link: 'Open Client Access',
|
||||||
|
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
|
||||||
|
const clientAccessLabel = ci18n.label;
|
||||||
|
const clientAccessDesc = ci18n.desc;
|
||||||
|
const clientAccessLink = ci18n.link;
|
||||||
|
const clientAccessWarning = ci18n.warning;
|
||||||
|
const pinLabel = 'PIN';
|
||||||
|
|
||||||
|
htmlBody += `
|
||||||
|
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||||
|
<strong style="font-size: 15px;">🔒 ${clientAccessLabel}</strong>
|
||||||
|
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
|
||||||
|
<p style="margin: 8px 0;">
|
||||||
|
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
|
||||||
|
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${clientAccessWarning}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap HTML body in styled template
|
// Wrap HTML body in styled template
|
||||||
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);
|
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { analyticsService } from './services/analytics.service';
|
|||||||
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
|
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { GalleryPage } from './pages/GalleryPage';
|
import { GalleryPage } from './pages/GalleryPage';
|
||||||
|
import { ClientAccessPage } from './pages/ClientAccessPage';
|
||||||
import { PreviewPage } from './pages/gallery/PreviewPage';
|
import { PreviewPage } from './pages/gallery/PreviewPage';
|
||||||
import { LegalPage } from './pages/public/LegalPage';
|
import { LegalPage } from './pages/public/LegalPage';
|
||||||
import {
|
import {
|
||||||
@@ -121,6 +122,11 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Public gallery routes */}
|
{/* Public gallery routes */}
|
||||||
<Route path="/gallery/preview" element={<PreviewPage />} />
|
<Route path="/gallery/preview" element={<PreviewPage />} />
|
||||||
|
<Route path="/gallery/:slug/client-access" element={
|
||||||
|
<GalleryAuthProvider>
|
||||||
|
<ClientAccessPage />
|
||||||
|
</GalleryAuthProvider>
|
||||||
|
} />
|
||||||
<Route path="/gallery/:slug/:token?" element={
|
<Route path="/gallery/:slug/:token?" element={
|
||||||
<GalleryAuthProvider>
|
<GalleryAuthProvider>
|
||||||
<GalleryPage />
|
<GalleryPage />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -201,6 +201,34 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
>
|
>
|
||||||
{t('photos.moveToCategory', 'Move to Category')}
|
{t('photos.moveToCategory', 'Move to Category')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
|
||||||
|
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
|
||||||
|
onPhotosDeleted();
|
||||||
|
} catch { toast.error(t('common.error')); }
|
||||||
|
}}
|
||||||
|
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('admin.photos.hideSelected', 'Hide')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
|
||||||
|
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
|
||||||
|
onPhotosDeleted();
|
||||||
|
} catch { toast.error(t('common.error')); }
|
||||||
|
}}
|
||||||
|
leftIcon={<Eye className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('admin.photos.showSelected', 'Show')}
|
||||||
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteSelected}
|
onClick={handleDeleteSelected}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
@@ -260,6 +288,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Visibility badge (#172) */}
|
||||||
|
{(photo as any).visibility === 'hidden' && (
|
||||||
|
<div className="absolute top-2 left-2 z-20">
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
|
||||||
|
<EyeOff className="w-3 h-3" />
|
||||||
|
{t('admin.photos.hidden', 'Hidden')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="aspect-square">
|
<div className="aspect-square">
|
||||||
{photo.thumbnail_url ? (
|
{photo.thumbnail_url ? (
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import type { FilterType } from './GalleryFilter';
|
|||||||
import { analyticsService } from '../../services/analytics.service';
|
import { analyticsService } from '../../services/analytics.service';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { Upload, Menu } from 'lucide-react';
|
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
|
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -42,8 +44,9 @@ interface GalleryViewProps {
|
|||||||
|
|
||||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { logout } = useGalleryAuth();
|
const { logout, isClient } = useGalleryAuth();
|
||||||
const { theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||||
@@ -274,6 +277,93 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
setStaticHeroPhoto(defaultHeroPhoto);
|
setStaticHeroPhoto(defaultHeroPhoto);
|
||||||
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
|
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
|
||||||
|
|
||||||
|
// Apply theme when settings are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsData && data?.event) {
|
||||||
|
let themeToApply = null;
|
||||||
|
const fullEvent = data.event; // Use the full event data from API
|
||||||
|
|
||||||
|
if (fullEvent.color_theme) {
|
||||||
|
try {
|
||||||
|
// Check if it's a valid JSON string
|
||||||
|
if (fullEvent.color_theme.startsWith('{')) {
|
||||||
|
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||||
|
themeToApply = eventTheme;
|
||||||
|
} else {
|
||||||
|
// Handle legacy theme names - check if it's a preset
|
||||||
|
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
||||||
|
if (preset) {
|
||||||
|
themeToApply = preset.config;
|
||||||
|
} else {
|
||||||
|
// Unknown theme name, fall back to global theme
|
||||||
|
if (settingsData.theme_config) {
|
||||||
|
themeToApply = settingsData.theme_config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid theme format - use default
|
||||||
|
// Fall back to global theme
|
||||||
|
if (settingsData.theme_config) {
|
||||||
|
themeToApply = settingsData.theme_config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (settingsData.theme_config) {
|
||||||
|
// No event theme, use global theme
|
||||||
|
themeToApply = settingsData.theme_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme with a small delay to ensure it overrides any global theme
|
||||||
|
if (themeToApply) {
|
||||||
|
// Use setTimeout to ensure this runs after any global theme application
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// If there's a hero photo, add it to gallery settings
|
||||||
|
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||||
|
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||||
|
// Apply hero photo ID to existing gallery settings
|
||||||
|
} else if (fullEvent.hero_photo_id) {
|
||||||
|
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||||
|
// Create gallery settings with hero photo ID
|
||||||
|
}
|
||||||
|
setTheme(themeToApply);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||||
|
|
||||||
|
// Client visibility toggle handler (#172)
|
||||||
|
const handleToggleVisibility = async (photoId: number, currentVisibility: string) => {
|
||||||
|
const newVisibility = currentVisibility === 'hidden' ? 'visible' : 'hidden';
|
||||||
|
try {
|
||||||
|
await galleryService.togglePhotoVisibility(slug, photoId, newVisibility);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle visibility:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkVisibility = async (visibility: 'visible' | 'hidden') => {
|
||||||
|
if (selectedPhotos.size === 0) return;
|
||||||
|
try {
|
||||||
|
await galleryService.bulkToggleVisibility(slug, Array.from(selectedPhotos), visibility);
|
||||||
|
setSelectedPhotos(new Set());
|
||||||
|
setIsSelectionMode(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to bulk toggle visibility:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client visibility stats
|
||||||
|
const visibleCount = useMemo(() => {
|
||||||
|
if (!isClient || !data?.photos) return 0;
|
||||||
|
return data.photos.filter(p => p.visibility !== 'hidden').length;
|
||||||
|
}, [isClient, data?.photos]);
|
||||||
|
|
||||||
|
const totalCount = data?.photos?.length || 0;
|
||||||
|
|
||||||
// Calculate days until expiration (null means never expires)
|
// Calculate days until expiration (null means never expires)
|
||||||
const daysUntilExpiration = event.expires_at
|
const daysUntilExpiration = event.expires_at
|
||||||
? differenceInDays(parseISO(event.expires_at), new Date())
|
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||||
@@ -677,6 +767,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Client Access Banner (#172) */}
|
||||||
|
{isClient && (
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
{t('clientAccess.banner')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2">
|
||||||
|
{t('clientAccess.visibleCount', { visible: visibleCount, total: totalCount })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelectionMode && selectedPhotos.size > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||||
|
onClick={() => handleBulkVisibility('hidden')}
|
||||||
|
>
|
||||||
|
{t('clientAccess.hideSelected')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Eye className="w-4 h-4" />}
|
||||||
|
onClick={() => handleBulkVisibility('visible')}
|
||||||
|
>
|
||||||
|
{t('clientAccess.showSelected')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search and Filters - Only for grid layout */}
|
{/* Search and Filters - Only for grid layout */}
|
||||||
{!showSidebar ? (
|
{!showSidebar ? (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -739,6 +866,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||||
welcomeMessage={event.welcome_message}
|
welcomeMessage={event.welcome_message}
|
||||||
|
isClient={isClient}
|
||||||
|
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
welcomeMessage?: string;
|
welcomeMessage?: string;
|
||||||
// Logout callback for full-page layouts
|
// Logout callback for full-page layouts
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
// Client visibility controls (#172)
|
||||||
|
isClient?: boolean;
|
||||||
|
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
@@ -100,7 +103,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
heroDividerStyle = 'wave',
|
heroDividerStyle = 'wave',
|
||||||
heroImageAnchor = 'center',
|
heroImageAnchor = 'center',
|
||||||
welcomeMessage,
|
welcomeMessage,
|
||||||
onLogout
|
onLogout,
|
||||||
|
isClient = false,
|
||||||
|
onToggleVisibility
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -231,6 +236,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
heroLogoPosition,
|
heroLogoPosition,
|
||||||
welcomeMessage,
|
welcomeMessage,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
isClient,
|
||||||
|
onToggleVisibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if we should show hero header (decoupled from layout)
|
// Determine if we should show hero header (decoupled from layout)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export interface BaseGalleryLayoutProps {
|
|||||||
};
|
};
|
||||||
// Logout callback for full-page layouts
|
// Logout callback for full-page layouts
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
// Client visibility controls (#172)
|
||||||
|
isClient?: boolean;
|
||||||
|
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
|
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
@@ -365,7 +365,9 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
useCanvasRendering = false,
|
useCanvasRendering = false,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions
|
feedbackOptions,
|
||||||
|
isClient = false,
|
||||||
|
onToggleVisibility
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
@@ -388,40 +390,61 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={gridClass}>
|
<div className={gridClass}>
|
||||||
{photos.map((photo, index) => (
|
{photos.map((photo, index) => {
|
||||||
<GridPhoto
|
const isHidden = photo.visibility === 'hidden';
|
||||||
key={photo.id}
|
return (
|
||||||
photo={photo}
|
<div key={photo.id} className={`relative ${isClient && isHidden ? 'opacity-40' : ''}`}>
|
||||||
isSelected={selectedPhotos.has(photo.id)}
|
<GridPhoto
|
||||||
isSelectionMode={isSelectionMode}
|
photo={photo}
|
||||||
onClick={() => onPhotoClick(index)}
|
isSelected={selectedPhotos.has(photo.id)}
|
||||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
isSelectionMode={isSelectionMode}
|
||||||
onDownload={(e) => onDownload(photo, e)}
|
onClick={() => onPhotoClick(index)}
|
||||||
animationType={animation}
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
allowDownloads={allowDownloads}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
slug={slug}
|
animationType={animation}
|
||||||
protectionLevel={protectionLevel}
|
allowDownloads={allowDownloads}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
slug={slug}
|
||||||
useCanvasRendering={useCanvasRendering}
|
protectionLevel={protectionLevel}
|
||||||
feedbackEnabled={feedbackEnabled}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
feedbackOptions={feedbackOptions}
|
useCanvasRendering={useCanvasRendering}
|
||||||
savedIdentity={savedIdentity}
|
feedbackEnabled={feedbackEnabled}
|
||||||
onRequireIdentity={(action, photoId) => {
|
feedbackOptions={feedbackOptions}
|
||||||
setPendingAction({ type: action, photoId });
|
savedIdentity={savedIdentity}
|
||||||
setShowIdentityModal(true);
|
onRequireIdentity={(action, photoId) => {
|
||||||
}}
|
setPendingAction({ type: action, photoId });
|
||||||
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
|
setShowIdentityModal(true);
|
||||||
onFeedbackChange={onFeedbackChange}
|
}}
|
||||||
liked={likedPhotoIds.has(photo.id)}
|
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
|
||||||
onLikeSuccess={() => {
|
onFeedbackChange={onFeedbackChange}
|
||||||
setLikedPhotoIds((prev) => {
|
liked={likedPhotoIds.has(photo.id)}
|
||||||
const next = new Set(prev);
|
onLikeSuccess={() => {
|
||||||
next.add(photo.id);
|
setLikedPhotoIds((prev) => {
|
||||||
return next;
|
const next = new Set(prev);
|
||||||
});
|
next.add(photo.id);
|
||||||
}}
|
return next;
|
||||||
/>
|
});
|
||||||
))}
|
}}
|
||||||
|
/>
|
||||||
|
{/* Client visibility toggle overlay (#172) */}
|
||||||
|
{isClient && onToggleVisibility && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleVisibility(photo.id, photo.visibility || 'visible');
|
||||||
|
}}
|
||||||
|
className={`absolute top-2 left-2 z-10 p-1.5 rounded-full shadow-md transition-colors ${
|
||||||
|
isHidden
|
||||||
|
? 'bg-red-500/90 text-white hover:bg-red-600'
|
||||||
|
: 'bg-white/90 text-neutral-700 hover:bg-white dark:bg-neutral-800/90 dark:text-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
title={isHidden ? 'Hidden from guests' : 'Visible to guests'}
|
||||||
|
>
|
||||||
|
{isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<FeedbackIdentityModal
|
<FeedbackIdentityModal
|
||||||
isOpen={showIdentityModal}
|
isOpen={showIdentityModal}
|
||||||
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
|
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
setActiveGallerySlug,
|
setActiveGallerySlug,
|
||||||
storeGalleryToken,
|
storeGalleryToken,
|
||||||
} from '../utils/galleryAuthStorage';
|
} from '../utils/galleryAuthStorage';
|
||||||
|
import type { GalleryAccessLevel } from '../types';
|
||||||
|
|
||||||
interface GalleryEvent {
|
interface GalleryEvent {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -37,7 +38,10 @@ const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent
|
|||||||
interface GalleryAuthContextType {
|
interface GalleryAuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
event: GalleryEvent | null;
|
event: GalleryEvent | null;
|
||||||
|
accessLevel: GalleryAccessLevel;
|
||||||
|
isClient: boolean;
|
||||||
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||||
|
clientLogin: (slug: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -60,6 +64,7 @@ interface GalleryAuthProviderProps {
|
|||||||
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
|
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||||
|
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [routeError, setRouteError] = useState<string | null>(null);
|
const [routeError, setRouteError] = useState<string | null>(null);
|
||||||
@@ -188,6 +193,14 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore access level from session storage
|
||||||
|
const storedAccessLevel = sessionStorage.getItem(`gallery_access_level_${currentSlug}`);
|
||||||
|
if (storedAccessLevel === 'client') {
|
||||||
|
setAccessLevel('client');
|
||||||
|
} else {
|
||||||
|
setAccessLevel('guest');
|
||||||
|
}
|
||||||
|
|
||||||
const initialise = async () => {
|
const initialise = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -285,15 +298,43 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clientLoginFn = async (slug: string, password: string) => {
|
||||||
|
try {
|
||||||
|
setRouteError(null);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await authService.clientLogin(slug, password);
|
||||||
|
if (response.token) {
|
||||||
|
storeGalleryToken(slug, response.token);
|
||||||
|
}
|
||||||
|
setActiveGallerySlug(slug);
|
||||||
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
|
setAccessLevel(response.accessLevel || 'client');
|
||||||
|
sessionStorage.setItem(`gallery_access_level_${slug}`, 'client');
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Invalid PIN');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
const currentSlug = routeInfo.slug;
|
const currentSlug = routeInfo.slug;
|
||||||
if (currentSlug) {
|
if (currentSlug) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
|
sessionStorage.removeItem(`gallery_access_level_${currentSlug}`);
|
||||||
clearGalleryToken(currentSlug);
|
clearGalleryToken(currentSlug);
|
||||||
}
|
}
|
||||||
authService.galleryLogout(currentSlug || undefined);
|
authService.galleryLogout(currentSlug || undefined);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
|
setAccessLevel('guest');
|
||||||
clearActiveGallerySlug();
|
clearActiveGallerySlug();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,7 +343,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
value={{
|
value={{
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
event,
|
event,
|
||||||
|
accessLevel,
|
||||||
|
isClient: accessLevel === 'client',
|
||||||
login,
|
login,
|
||||||
|
clientLogin: clientLoginFn,
|
||||||
logout,
|
logout,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: routeError ?? error,
|
error: routeError ?? error,
|
||||||
|
|||||||
@@ -1758,6 +1758,13 @@
|
|||||||
"username": "Benutzernamen wählen",
|
"username": "Benutzernamen wählen",
|
||||||
"password": "Passwort erstellen",
|
"password": "Passwort erstellen",
|
||||||
"submit": "Konto erstellen"
|
"submit": "Konto erstellen"
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"hidden": "Versteckt",
|
||||||
|
"hideSelected": "Ausblenden",
|
||||||
|
"showSelected": "Einblenden",
|
||||||
|
"hiddenSuccess": "Fotos für Gäste ausgeblendet",
|
||||||
|
"visibleSuccess": "Fotos jetzt für Gäste sichtbar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -2576,6 +2583,31 @@
|
|||||||
"exportFiltered": "Gefilterte Fotos exportieren",
|
"exportFiltered": "Gefilterte Fotos exportieren",
|
||||||
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren"
|
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren"
|
||||||
},
|
},
|
||||||
|
"clientAccess": {
|
||||||
|
"title": "Kundenzugang",
|
||||||
|
"description": "Geben Sie Ihre PIN ein, um Fotos vor der Veröffentlichung zu überprüfen und auszuwählen.",
|
||||||
|
"pinLabel": "Kunden-PIN",
|
||||||
|
"pinPlaceholder": "PIN eingeben",
|
||||||
|
"enterPin": "Geben Sie die PIN ein, die Sie erhalten haben",
|
||||||
|
"invalidPin": "Ungültige PIN. Bitte versuchen Sie es erneut.",
|
||||||
|
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"loginButton": "Zugang erhalten",
|
||||||
|
"guestHint": "Nur Fotos ansehen?",
|
||||||
|
"guestLink": "Zur Gästegalerie",
|
||||||
|
"banner": "Kundenmodus",
|
||||||
|
"visibleCount": "{{visible}} von {{total}} Fotos für Gäste sichtbar",
|
||||||
|
"hideSelected": "Ausgewählte ausblenden",
|
||||||
|
"showSelected": "Ausgewählte einblenden",
|
||||||
|
"adminTitle": "Kundenzugang",
|
||||||
|
"enableToggle": "Kundenzugang aktivieren",
|
||||||
|
"enableDescription": "Ermöglichen Sie Kunden, Fotos zu überprüfen und auszublenden, bevor die Galerie mit den Gästen geteilt wird.",
|
||||||
|
"pinHelperText": "Der Kunde verwendet diese PIN, um die Überprüfungsseite aufzurufen.",
|
||||||
|
"pinUpdated": "Kunden-PIN aktualisiert",
|
||||||
|
"setPin": "PIN festlegen",
|
||||||
|
"linkLabel": "Kundenzugangs-Link",
|
||||||
|
"regenerateToken": "Link neu generieren",
|
||||||
|
"tokenRegenerated": "Kundenzugangs-Link neu generiert"
|
||||||
|
},
|
||||||
"adminLogin": {
|
"adminLogin": {
|
||||||
"title": "Admin-Anmeldung",
|
"title": "Admin-Anmeldung",
|
||||||
"subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten",
|
"subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten",
|
||||||
|
|||||||
@@ -1471,6 +1471,13 @@
|
|||||||
"username": "Choose a Username",
|
"username": "Choose a Username",
|
||||||
"password": "Create Password",
|
"password": "Create Password",
|
||||||
"submit": "Create Account"
|
"submit": "Create Account"
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"hidden": "Hidden",
|
||||||
|
"hideSelected": "Hide",
|
||||||
|
"showSelected": "Show",
|
||||||
|
"hiddenSuccess": "Photos hidden from guests",
|
||||||
|
"visibleSuccess": "Photos now visible to guests"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -2337,5 +2344,30 @@
|
|||||||
"needHelp": "Need help? Contact",
|
"needHelp": "Need help? Contact",
|
||||||
"poweredBy": "Powered by PicPeak",
|
"poweredBy": "Powered by PicPeak",
|
||||||
"devModeHint": "Development Mode: Use email: [email protected], password: admin123"
|
"devModeHint": "Development Mode: Use email: [email protected], password: admin123"
|
||||||
|
},
|
||||||
|
"clientAccess": {
|
||||||
|
"title": "Client Access",
|
||||||
|
"description": "Review photos and manage visibility before the gallery is shared with guests.",
|
||||||
|
"pinLabel": "Client PIN",
|
||||||
|
"pinPlaceholder": "Enter your client PIN",
|
||||||
|
"enterPin": "Please enter your client PIN",
|
||||||
|
"invalidPin": "Invalid PIN. Please try again.",
|
||||||
|
"loginFailed": "Authentication failed. Please try again.",
|
||||||
|
"loginButton": "Access Gallery",
|
||||||
|
"guestHint": "Looking for the guest gallery?",
|
||||||
|
"guestLink": "Go to guest view",
|
||||||
|
"banner": "Client Mode",
|
||||||
|
"visibleCount": "{{visible}} of {{total}} photos visible to guests",
|
||||||
|
"hideSelected": "Hide Selected",
|
||||||
|
"showSelected": "Show Selected",
|
||||||
|
"adminTitle": "Client Access",
|
||||||
|
"enableToggle": "Enable Client Access",
|
||||||
|
"enableDescription": "Allow clients to review and hide photos before the gallery is shared with guests.",
|
||||||
|
"pinHelperText": "The client will use this PIN to access the review page.",
|
||||||
|
"pinUpdated": "Client PIN updated",
|
||||||
|
"setPin": "Set PIN",
|
||||||
|
"linkLabel": "Client Access Link",
|
||||||
|
"regenerateToken": "Regenerate link",
|
||||||
|
"tokenRegenerated": "Client access link regenerated"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1075
-1043
File diff suppressed because it is too large
Load Diff
@@ -1449,6 +1449,13 @@
|
|||||||
"username": "Выберите имя пользователя",
|
"username": "Выберите имя пользователя",
|
||||||
"password": "Создайте пароль",
|
"password": "Создайте пароль",
|
||||||
"submit": "Создать аккаунт"
|
"submit": "Создать аккаунт"
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"hidden": "Скрыто",
|
||||||
|
"hideSelected": "Скрыть",
|
||||||
|
"showSelected": "Показать",
|
||||||
|
"hiddenSuccess": "Фотографии скрыты от гостей",
|
||||||
|
"visibleSuccess": "Фотографии теперь видны гостям"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -2312,5 +2319,30 @@
|
|||||||
"needHelp": "Нужна помощь? Свяжитесь",
|
"needHelp": "Нужна помощь? Свяжитесь",
|
||||||
"poweredBy": "Работает на PicPeak",
|
"poweredBy": "Работает на PicPeak",
|
||||||
"devModeHint": "Режим разработки: используйте email: [email protected], пароль: admin123"
|
"devModeHint": "Режим разработки: используйте email: [email protected], пароль: admin123"
|
||||||
|
},
|
||||||
|
"clientAccess": {
|
||||||
|
"title": "Доступ клиента",
|
||||||
|
"description": "Введите PIN-код для просмотра и выбора фотографий перед публикацией.",
|
||||||
|
"pinLabel": "PIN-код клиента",
|
||||||
|
"pinPlaceholder": "Введите PIN-код",
|
||||||
|
"enterPin": "Введите PIN-код, который вы получили",
|
||||||
|
"invalidPin": "Неверный PIN-код. Попробуйте ещё раз.",
|
||||||
|
"loginFailed": "Не удалось войти. Попробуйте ещё раз.",
|
||||||
|
"loginButton": "Получить доступ",
|
||||||
|
"guestHint": "Просто хотите посмотреть фотографии?",
|
||||||
|
"guestLink": "Перейти в гостевую галерею",
|
||||||
|
"banner": "Режим клиента",
|
||||||
|
"visibleCount": "{{visible}} из {{total}} фотографий видны гостям",
|
||||||
|
"hideSelected": "Скрыть выбранные",
|
||||||
|
"showSelected": "Показать выбранные",
|
||||||
|
"adminTitle": "Доступ клиента",
|
||||||
|
"enableToggle": "Включить доступ клиента",
|
||||||
|
"enableDescription": "Позволить клиентам просматривать и скрывать фотографии до того, как галерея будет открыта для гостей.",
|
||||||
|
"pinHelperText": "Клиент будет использовать этот PIN-код для доступа к странице проверки.",
|
||||||
|
"pinUpdated": "PIN-код клиента обновлён",
|
||||||
|
"setPin": "Установить PIN",
|
||||||
|
"linkLabel": "Ссылка для клиента",
|
||||||
|
"regenerateToken": "Сгенерировать новую ссылку",
|
||||||
|
"tokenRegenerated": "Ссылка для клиента обновлена"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { AlertCircle, Lock } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Card, CardContent, Input, Button, Loading } from '../components/common';
|
||||||
|
import { useGalleryAuth } from '../contexts';
|
||||||
|
import { useGalleryInfo } from '../hooks/useGallery';
|
||||||
|
import { api } from '../config/api';
|
||||||
|
import { buildResourceUrl } from '../utils/url';
|
||||||
|
|
||||||
|
export const ClientAccessPage: React.FC = () => {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated, isClient, clientLogin, isLoading: authLoading } = useGalleryAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
|
||||||
|
|
||||||
|
const { data: settingsData } = useQuery({
|
||||||
|
queryKey: ['gallery-settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/public/settings');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If already authenticated as client, redirect to gallery
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated && isClient && slug) {
|
||||||
|
navigate(`/gallery/${slug}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isClient, slug, navigate]);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!pin.trim()) {
|
||||||
|
setLoginError(t('clientAccess.enterPin'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
setLoginError(t('errors.galleryNotFound'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
setLoginError(null);
|
||||||
|
await clientLogin(slug, pin);
|
||||||
|
navigate(`/gallery/${slug}`, { replace: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
const statusCode = error.response?.status;
|
||||||
|
if (statusCode === 401) {
|
||||||
|
setLoginError(t('clientAccess.invalidPin'));
|
||||||
|
} else if (statusCode === 423) {
|
||||||
|
setLoginError(t('auth.tooManyAttempts'));
|
||||||
|
} else {
|
||||||
|
setLoginError(t('clientAccess.loginFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingInfo || authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loading size="lg" text={t('gallery.loading')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoError || !galleryInfo) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{settingsData?.branding_logo_url && (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<img
|
||||||
|
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||||
|
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||||
|
className="h-16 w-auto object-contain mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Card className="max-w-md w-full mx-4">
|
||||||
|
<CardContent className="text-center py-12">
|
||||||
|
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
|
||||||
|
<p className="text-neutral-600">{t('errors.galleryNotFoundMessage')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Logo */}
|
||||||
|
{settingsData?.branding_logo_url && (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<img
|
||||||
|
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||||
|
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||||
|
className="h-16 w-auto object-contain mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-center px-4">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Lock className="w-8 h-8 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('clientAccess.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
|
||||||
|
{galleryInfo.event_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-500 mt-1">
|
||||||
|
{t('clientAccess.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label={t('clientAccess.pinLabel')}
|
||||||
|
placeholder={t('clientAccess.pinPlaceholder')}
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPin(e.target.value);
|
||||||
|
setLoginError(null);
|
||||||
|
}}
|
||||||
|
error={loginError || undefined}
|
||||||
|
leftIcon={<Lock className="w-5 h-5" />}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isLoggingIn}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
>
|
||||||
|
{t('clientAccess.loginButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 text-center">
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('clientAccess.guestHint')}{' '}
|
||||||
|
<Link
|
||||||
|
to={`/gallery/${slug}`}
|
||||||
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t('clientAccess.guestLink')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/impressum"
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
{t('legal.impressum')}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-neutral-400">|</span>
|
||||||
|
<Link
|
||||||
|
to="/datenschutz"
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
{t('legal.datenschutz')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-2 text-neutral-500">
|
||||||
|
Powered by <span className="font-semibold">PicPeak</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Image
|
Image,
|
||||||
|
Key
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -59,6 +60,9 @@ interface FormData {
|
|||||||
rate_limit_window_minutes?: number;
|
rate_limit_window_minutes?: number;
|
||||||
rate_limit_max_requests?: number;
|
rate_limit_max_requests?: number;
|
||||||
};
|
};
|
||||||
|
// Client access (#172)
|
||||||
|
client_access_enabled: boolean;
|
||||||
|
client_password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback event types (used when API is unavailable)
|
// Fallback event types (used when API is unavailable)
|
||||||
@@ -114,8 +118,10 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
rate_limit_window_minutes: 15,
|
rate_limit_window_minutes: 15,
|
||||||
rate_limit_max_requests: 10,
|
rate_limit_max_requests: 10,
|
||||||
},
|
},
|
||||||
|
client_access_enabled: false,
|
||||||
|
client_password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
@@ -310,6 +316,9 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
require_name_email: feedbackSettings.require_name_email,
|
require_name_email: feedbackSettings.require_name_email,
|
||||||
moderate_comments: feedbackSettings.moderate_comments,
|
moderate_comments: feedbackSettings.moderate_comments,
|
||||||
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||||
|
// Client access (#172)
|
||||||
|
client_access_enabled: formData.client_access_enabled,
|
||||||
|
client_password: formData.client_access_enabled ? formData.client_password : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload);
|
||||||
@@ -742,6 +751,44 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Client Access (#172) */}
|
||||||
|
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
|
checked={formData.client_access_enabled}
|
||||||
|
onChange={(e) => setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
client_access_enabled: e.target.checked,
|
||||||
|
client_password: e.target.checked ? prev.client_password : '',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t('clientAccess.enableToggle')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
{t('clientAccess.enableDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{formData.client_access_enabled && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={t('clientAccess.pinLabel')}
|
||||||
|
placeholder={t('clientAccess.pinPlaceholder')}
|
||||||
|
value={formData.client_password}
|
||||||
|
onChange={handleInputChange('client_password')}
|
||||||
|
leftIcon={<Key className="w-5 h-5" />}
|
||||||
|
helperText={t('clientAccess.pinHelperText')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* User Upload Settings */}
|
{/* User Upload Settings */}
|
||||||
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<label className="flex items-center gap-3">
|
<label className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
rate_limit_max_requests: 10,
|
rate_limit_max_requests: 10,
|
||||||
});
|
});
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const [copiedClientLink, setCopiedClientLink] = useState(false);
|
||||||
|
const [clientPin, setClientPin] = useState('');
|
||||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||||
const [showExternalImport, setShowExternalImport] = useState(false);
|
const [showExternalImport, setShowExternalImport] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||||
@@ -263,7 +265,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
|
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// Fetch event details
|
// Fetch event details
|
||||||
const { data: event, isLoading: eventLoading } = useQuery({
|
const { data: event, isLoading: eventLoading, refetch: refetchEvent } = useQuery({
|
||||||
queryKey: ['admin-event', id],
|
queryKey: ['admin-event', id],
|
||||||
queryFn: () => eventsService.getEvent(parseInt(id!)),
|
queryFn: () => eventsService.getEvent(parseInt(id!)),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -1533,7 +1535,135 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Client Access (#172) */}
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
{t('clientAccess.adminTitle')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
|
checked={!!event?.client_access_enabled}
|
||||||
|
onChange={async (e) => {
|
||||||
|
try {
|
||||||
|
await eventsService.updateEvent(event.id, { client_access_enabled: e.target.checked });
|
||||||
|
refetchEvent();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={event?.is_archived}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t('clientAccess.enableToggle')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
{t('clientAccess.enableDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{event?.client_access_enabled && (
|
||||||
|
<>
|
||||||
|
{/* Set/Change PIN */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('clientAccess.pinLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={clientPin}
|
||||||
|
onChange={(e) => setClientPin(e.target.value)}
|
||||||
|
placeholder={t('clientAccess.pinPlaceholder')}
|
||||||
|
className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<Key className="w-4 h-4" />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!clientPin.trim()) return;
|
||||||
|
try {
|
||||||
|
await eventsService.updateEvent(event.id, { client_password: clientPin });
|
||||||
|
setClientPin('');
|
||||||
|
toast.success(t('clientAccess.pinUpdated'));
|
||||||
|
refetchEvent();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!clientPin.trim()}
|
||||||
|
>
|
||||||
|
{t('clientAccess.setPin')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client access link */}
|
||||||
|
{event?.client_share_token && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('clientAccess.linkLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={`${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
leftIcon={copiedClientLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
onClick={async () => {
|
||||||
|
const link = `${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
} catch {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = link;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
setCopiedClientLink(true);
|
||||||
|
setTimeout(() => setCopiedClientLink(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedClientLink ? t('events.copied') : t('events.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 text-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await eventsService.updateEvent(event.id, { regenerate_client_token: true });
|
||||||
|
toast.success(t('clientAccess.tokenRegenerated'));
|
||||||
|
refetchEvent();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clientAccess.regenerateToken')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export const authService = {
|
|||||||
return normalizeGalleryResponse(response.data);
|
return normalizeGalleryResponse(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async clientLogin(slug: string, password: string): Promise<GalleryAuthResponse> {
|
||||||
|
const response = await api.post<GalleryAuthResponse>(`/auth/gallery/${slug}/client-login`, {
|
||||||
|
password
|
||||||
|
});
|
||||||
|
return normalizeGalleryResponse(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
||||||
const response = await api.post<GalleryAuthResponse>('/auth/gallery/share-login', {
|
const response = await api.post<GalleryAuthResponse>('/auth/gallery/share-login', {
|
||||||
slug,
|
slug,
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ export const galleryService = {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Toggle photo visibility (client-only)
|
||||||
|
async togglePhotoVisibility(slug: string, photoId: number, visibility: 'visible' | 'hidden'): Promise<void> {
|
||||||
|
await api.patch(`/gallery/${slug}/photos/${photoId}/visibility`, { visibility });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bulk toggle photo visibility (client-only)
|
||||||
|
async bulkToggleVisibility(slug: string, photoIds: number[], visibility: 'visible' | 'hidden'): Promise<void> {
|
||||||
|
await api.patch(`/gallery/${slug}/photos/visibility/bulk`, { photoIds, visibility });
|
||||||
|
},
|
||||||
|
|
||||||
// Get gallery statistics
|
// Get gallery statistics
|
||||||
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
||||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||||
|
|||||||
@@ -73,12 +73,19 @@ class PhotosService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
|
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
|
||||||
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
|
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
|
||||||
photoIds,
|
photoIds,
|
||||||
updates: { category_id: categoryId }
|
updates: { category_id: categoryId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkUpdatePhotos(eventId: number, photoIds: number[], updates: Record<string, unknown>): Promise<void> {
|
||||||
|
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
|
||||||
|
photoIds,
|
||||||
|
updates
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
|
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
|
||||||
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
|
|||||||
@@ -55,8 +55,13 @@ export interface Event {
|
|||||||
css_template_id?: number | null;
|
css_template_id?: number | null;
|
||||||
// Photo cap
|
// Photo cap
|
||||||
photo_cap?: number | null;
|
photo_cap?: number | null;
|
||||||
|
// Client access (#172)
|
||||||
|
client_access_enabled?: boolean;
|
||||||
|
client_share_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GalleryAccessLevel = 'guest' | 'client';
|
||||||
|
|
||||||
export interface GalleryInfo {
|
export interface GalleryInfo {
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
@@ -92,6 +97,8 @@ export interface Photo {
|
|||||||
audio_codec?: string;
|
audio_codec?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
// Visibility (#172)
|
||||||
|
visibility?: 'visible' | 'hidden';
|
||||||
// Feedback fields
|
// Feedback fields
|
||||||
has_feedback?: boolean;
|
has_feedback?: boolean;
|
||||||
average_rating?: number;
|
average_rating?: number;
|
||||||
@@ -206,6 +213,7 @@ export interface GalleryAuthResponse {
|
|||||||
require_password?: boolean;
|
require_password?: boolean;
|
||||||
photo_cap?: number | null;
|
photo_cap?: number | null;
|
||||||
};
|
};
|
||||||
|
accessLevel?: GalleryAccessLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Error type
|
// API Error type
|
||||||
|
|||||||
Reference in New Issue
Block a user