Merge pull request #527 from the-luap/fix/bug-batch-518
fix(bug-batch-518): lightbox comments toggle + further fixes
This commit is contained in:
@@ -35,6 +35,7 @@ const { getStorage } = require('../services/storage');
|
|||||||
const {
|
const {
|
||||||
buildOgMetadata,
|
buildOgMetadata,
|
||||||
handleGalleryOgCover,
|
handleGalleryOgCover,
|
||||||
|
isSocialCrawler,
|
||||||
} = require('../services/galleryOgService');
|
} = require('../services/galleryOgService');
|
||||||
|
|
||||||
// The service hits two tables in sequence:
|
// The service hits two tables in sequence:
|
||||||
@@ -237,3 +238,52 @@ describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
|
|||||||
expect(ensureThumbnail).not.toHaveBeenCalled();
|
expect(ensureThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression for #521 — WhatsApp Business API + 3rd-party preview
|
||||||
|
// services use UAs that aren't "WhatsApp/X.Y.Z". If isSocialCrawler
|
||||||
|
// misses them, those requests fall through to the static SPA shell
|
||||||
|
// and the link preview ends up unbranded.
|
||||||
|
describe('isSocialCrawler — extended bot coverage (#521)', () => {
|
||||||
|
it('matches every UA the README/changelog claims to support', () => {
|
||||||
|
// Pin the contract: each listed UA must hit the crawler path so the
|
||||||
|
// nginx rewrite + backend OG handler stay in sync. Adding a new UA
|
||||||
|
// here without also adding it to nginx.conf would silently regress.
|
||||||
|
const knownBots = [
|
||||||
|
// Main WhatsApp app
|
||||||
|
'WhatsApp/2.23.20.0',
|
||||||
|
// WhatsApp Business / Cloud API variants
|
||||||
|
'WhatsAppBot/1.0',
|
||||||
|
'wa-bot/2.0',
|
||||||
|
// Other messaging app crawlers
|
||||||
|
'facebookexternalhit/1.1',
|
||||||
|
'Twitterbot/1.0',
|
||||||
|
'Slackbot-LinkExpanding 1.0',
|
||||||
|
'TelegramBot (like TwitterBot)',
|
||||||
|
// 3rd-party preview services used by business-messaging stacks
|
||||||
|
'LinkPreview/1.0',
|
||||||
|
'Slack-ImgProxy/1.0',
|
||||||
|
];
|
||||||
|
for (const ua of knownBots) {
|
||||||
|
expect(isSocialCrawler(ua)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a regular browser UA', () => {
|
||||||
|
const browsers = [
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15',
|
||||||
|
// Browser UA that happens to contain "Mobile" — guard against an
|
||||||
|
// over-broad regex landing on it.
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36',
|
||||||
|
];
|
||||||
|
for (const ua of browsers) {
|
||||||
|
expect(isSocialCrawler(ua)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for null/empty/undefined UAs', () => {
|
||||||
|
expect(isSocialCrawler(null)).toBe(false);
|
||||||
|
expect(isSocialCrawler(undefined)).toBe(false);
|
||||||
|
expect(isSocialCrawler('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { slugify } = require('../utils/slug');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
@@ -217,7 +218,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
const insertResult = await db('photo_categories').insert({
|
const insertResult = await db('photo_categories').insert({
|
||||||
event_id: archive.id,
|
event_id: archive.id,
|
||||||
name: categoryName,
|
name: categoryName,
|
||||||
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
slug: slugify(categoryName),
|
||||||
created_at: new Date()
|
created_at: new Date()
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { slugify } = require('../utils/slug');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -439,7 +440,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
allow_presigned_download = false,
|
allow_presigned_download = false,
|
||||||
require_password: requirePasswordInput,
|
require_password: requirePasswordInput,
|
||||||
// Feedback settings
|
// Feedback settings
|
||||||
feedback_enabled = false,
|
feedback_enabled: feedbackEnabledInput,
|
||||||
allow_ratings = true,
|
allow_ratings = true,
|
||||||
allow_likes = true,
|
allow_likes = true,
|
||||||
allow_comments = true,
|
allow_comments = true,
|
||||||
@@ -507,6 +508,17 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
}
|
}
|
||||||
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
|
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
|
||||||
|
|
||||||
|
// Default feedback_enabled from global "event_default_feedback_enabled"
|
||||||
|
// setting when the body omits it (#520 — same pattern as require_password
|
||||||
|
// above, lets admins make Guest Feedback ON the out-of-box default for
|
||||||
|
// new events instead of toggling it on every time).
|
||||||
|
let feedbackEnabledFallback = false;
|
||||||
|
if (feedbackEnabledInput === undefined) {
|
||||||
|
const setting = await readBooleanSetting('event_default_feedback_enabled');
|
||||||
|
if (setting !== undefined) feedbackEnabledFallback = setting;
|
||||||
|
}
|
||||||
|
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
logger.debug('Download control values', {
|
logger.debug('Download control values', {
|
||||||
allow_downloads,
|
allow_downloads,
|
||||||
@@ -538,12 +550,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug. Uses the shared util so accented names
|
||||||
const processedEventName = event_name
|
// (Família, Decoração, etc.) get transliterated instead of dropped
|
||||||
.toLowerCase()
|
// — see backend/src/utils/slug.js for the why (#525).
|
||||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
const processedEventName = slugify(event_name);
|
||||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
|
||||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
|
||||||
|
|
||||||
// Use event_date in slug if provided, otherwise use random suffix
|
// Use event_date in slug if provided, otherwise use random suffix
|
||||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||||
|
|||||||
@@ -226,15 +226,28 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
|||||||
let photoType = 'individual'; // default
|
let photoType = 'individual'; // default
|
||||||
let categoryName = 'individual';
|
let categoryName = 'individual';
|
||||||
|
|
||||||
// Look up the actual category from database if provided
|
// Look up the actual category from database if provided. Scope the
|
||||||
|
// lookup to (event_id = event.id OR is_global = true) — same contract
|
||||||
|
// the public v1 upload route enforces (#500 / #525). Without it, the
|
||||||
|
// admin upload silently accepts any category id including ones that
|
||||||
|
// belong to a different event. The v1 route rejects out-of-scope ids
|
||||||
|
// with 400; mirror that here so admin and v1 stay consistent.
|
||||||
if (parsedCategoryId && !isNaN(parsedCategoryId)) {
|
if (parsedCategoryId && !isNaN(parsedCategoryId)) {
|
||||||
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
const category = await db('photo_categories')
|
||||||
if (category) {
|
.where({ id: parsedCategoryId })
|
||||||
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
|
.andWhere(function () {
|
||||||
// Use category slug for type determination
|
this.where({ event_id: event.id }).orWhere('is_global', true);
|
||||||
if (category.slug === 'collage' || category.slug === 'collages') {
|
})
|
||||||
photoType = 'collage';
|
.first();
|
||||||
}
|
if (!category) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Unknown or out-of-scope category_id ${parsedCategoryId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
// Use category slug for type determination
|
||||||
|
if (category.slug === 'collage' || category.slug === 'collages') {
|
||||||
|
photoType = 'collage';
|
||||||
}
|
}
|
||||||
} else if (category_id === 'collage') {
|
} else if (category_id === 'collage') {
|
||||||
// For backwards compatibility, accept string values
|
// For backwards compatibility, accept string values
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const bcrypt = require('bcrypt');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { slugify } = require('../utils/slug');
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
@@ -125,8 +126,8 @@ router.post('/', adminAuth, [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug — slugify() handles accents (see #525).
|
||||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ router.get('/', async (req, res) => {
|
|||||||
.orWhereIn('setting_key', [
|
.orWhereIn('setting_key', [
|
||||||
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
|
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
|
||||||
'event_default_require_password',
|
'event_default_require_password',
|
||||||
|
'event_default_feedback_enabled',
|
||||||
'gallery_show_filter_bar',
|
'gallery_show_filter_bar',
|
||||||
'event_phone_field_enabled'
|
'event_phone_field_enabled'
|
||||||
]);
|
]);
|
||||||
@@ -120,6 +121,10 @@ router.get('/', async (req, res) => {
|
|||||||
event_require_expiration: settingsObject.event_require_expiration !== false,
|
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||||
// Default value for "Require password" toggle in event creation form
|
// Default value for "Require password" toggle in event creation form
|
||||||
event_default_require_password: settingsObject.event_default_require_password !== false,
|
event_default_require_password: settingsObject.event_default_require_password !== false,
|
||||||
|
// Default value for the "Guest Feedback enabled" toggle (#520).
|
||||||
|
// Defaults to false (matches the prior hard-coded form default), so
|
||||||
|
// existing installs see no behaviour change until an admin flips it.
|
||||||
|
event_default_feedback_enabled: settingsObject.event_default_feedback_enabled === true,
|
||||||
// Phone-number field on events is opt-in (#322).
|
// Phone-number field on events is opt-in (#322).
|
||||||
event_phone_field_enabled: settingsObject.event_phone_field_enabled === true,
|
event_phone_field_enabled: settingsObject.event_phone_field_enabled === true,
|
||||||
// Whether to show the search/sort filter bar in public galleries (default: true)
|
// Whether to show the search/sort filter bar in public galleries (default: true)
|
||||||
|
|||||||
@@ -79,6 +79,32 @@ jest.mock('multer', () => {
|
|||||||
return factory;
|
return factory;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stub sharp so the happy-path test doesn't actually decode an image
|
||||||
|
// (the temp file is a 0-byte placeholder — see the beforeAll below).
|
||||||
|
jest.mock('sharp', () => jest.fn(() => ({
|
||||||
|
metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Thumbnail + storage are network/fs-heavy; stub to constant resolves
|
||||||
|
// so the test stays a pure unit test of the route handler's contract.
|
||||||
|
jest.mock('../../../services/imageProcessor', () => ({
|
||||||
|
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/fake_thumb.jpg'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../services/storage', () => ({
|
||||||
|
getStorage: jest.fn(() => ({
|
||||||
|
putFromFile: jest.fn().mockResolvedValue(undefined),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// webhookService.fire is wrapped in try/catch in the route, so a
|
||||||
|
// missing mock would still let the test pass — but stubbing it
|
||||||
|
// silences the predictable failure log so the test output stays clean.
|
||||||
|
jest.mock('../../../services/webhookService', () => ({
|
||||||
|
fire: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fsSync = require('fs');
|
||||||
const { db } = require('../../../database/db');
|
const { db } = require('../../../database/db');
|
||||||
const eventsRouter = require('../events');
|
const eventsRouter = require('../events');
|
||||||
|
|
||||||
@@ -145,3 +171,92 @@ describe('v1 POST /events/:id/photos — category scoping', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('v1 POST /events/:id/photos — happy path (#525)', () => {
|
||||||
|
const FAKE_TMP = '/tmp/fake-v1-upload.jpg';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Recreate the temp file on every test — the handler calls
|
||||||
|
// fs.unlink(tempPath) after a successful upload, so a beforeAll
|
||||||
|
// would leave the second test without an inode for statSync to
|
||||||
|
// read (manifests as 500 Internal Server Error).
|
||||||
|
fsSync.writeFileSync(FAKE_TMP, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try { fsSync.unlinkSync(FAKE_TMP); } catch { /* may have been unlinked by the handler */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts the photo and returns 201 with the resolved category_id', async () => {
|
||||||
|
// Three db() calls in sequence on the happy path:
|
||||||
|
// 1. events lookup
|
||||||
|
// 2. photo_categories lookup (returns a valid in-scope row)
|
||||||
|
// 3. photos insert returning the new id
|
||||||
|
const eventChain = buildChain({
|
||||||
|
firstResult: { id: 42, slug: 'wedding-2026', event_name: 'Wedding 2026' },
|
||||||
|
});
|
||||||
|
const categoryChain = buildChain({
|
||||||
|
firstResult: { id: 7, slug: 'ceremony', name: 'Ceremony', event_id: 42 },
|
||||||
|
});
|
||||||
|
const insertChain = {
|
||||||
|
...buildChain({ insertResult: [{ id: 101 }] }),
|
||||||
|
returning: jest.fn().mockResolvedValue([{ id: 101 }]),
|
||||||
|
};
|
||||||
|
// Override insert so the returning() call is chainable
|
||||||
|
insertChain.insert = jest.fn(() => insertChain);
|
||||||
|
db.__setImplementations(eventChain, categoryChain, insertChain);
|
||||||
|
|
||||||
|
const response = await request(buildApp())
|
||||||
|
.post('/events/42/photos')
|
||||||
|
.send({ category_id: '7' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// Response shape pins the v1 API contract — id + category_id are
|
||||||
|
// the fields the n8n / API-token use case depends on (see #500).
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
id: 101,
|
||||||
|
category_id: 7,
|
||||||
|
size_bytes: 0,
|
||||||
|
thumbnail_path: 'thumbnails/fake_thumb.jpg',
|
||||||
|
});
|
||||||
|
expect(response.body.filename).toMatch(/^\d+_[a-f0-9]+\.jpg$/);
|
||||||
|
expect(response.body.path).toMatch(/^wedding-2026\/\d+_[a-f0-9]+\.jpg$/);
|
||||||
|
|
||||||
|
// The insert payload should carry the resolved category_id and the
|
||||||
|
// 'individual' photo type (the test category slug isn't 'collage').
|
||||||
|
const insertedRow = insertChain.insert.mock.calls[0][0];
|
||||||
|
expect(insertedRow).toMatchObject({
|
||||||
|
event_id: 42,
|
||||||
|
category_id: 7,
|
||||||
|
type: 'individual',
|
||||||
|
media_type: 'image',
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips photo type to collage when the category slug is "collage"', async () => {
|
||||||
|
const eventChain = buildChain({
|
||||||
|
firstResult: { id: 42, slug: 'wedding-2026' },
|
||||||
|
});
|
||||||
|
const categoryChain = buildChain({
|
||||||
|
firstResult: { id: 9, slug: 'collage', name: 'Collage', event_id: 42 },
|
||||||
|
});
|
||||||
|
const insertChain = {
|
||||||
|
...buildChain(),
|
||||||
|
returning: jest.fn().mockResolvedValue([{ id: 202 }]),
|
||||||
|
};
|
||||||
|
insertChain.insert = jest.fn(() => insertChain);
|
||||||
|
db.__setImplementations(eventChain, categoryChain, insertChain);
|
||||||
|
|
||||||
|
await request(buildApp())
|
||||||
|
.post('/events/42/photos')
|
||||||
|
.send({ category_id: '9' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(insertChain.insert.mock.calls[0][0]).toMatchObject({
|
||||||
|
category_id: 9,
|
||||||
|
type: 'collage',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth
|
|||||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
|
const { slugify } = require('../../utils/slug');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@ const photoUpload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const slugify = (s) =>
|
// slugify now imported from ../../utils/slug — shared with adminEvents
|
||||||
String(s).toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
// and events.js so the diacritic fix from #502 lands here too (#525).
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
// POST /events — create event
|
// POST /events — create event
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
|
|||||||
/facebookexternalhit/i,
|
/facebookexternalhit/i,
|
||||||
/facebot/i,
|
/facebot/i,
|
||||||
/Twitterbot/i,
|
/Twitterbot/i,
|
||||||
|
// WhatsApp's main app crawler is "WhatsApp/X.Y.Z"; the Business
|
||||||
|
// API and some Cloud API senders use "WhatsAppBot" or "wa-bot/" —
|
||||||
|
// detect both so API-driven sends get the rich preview too (#521).
|
||||||
/WhatsApp/i,
|
/WhatsApp/i,
|
||||||
|
/WhatsAppBot/i,
|
||||||
|
/wa-bot/i,
|
||||||
/Slackbot/i,
|
/Slackbot/i,
|
||||||
/TelegramBot/i,
|
/TelegramBot/i,
|
||||||
/SkypeUriPreview/i,
|
/SkypeUriPreview/i,
|
||||||
@@ -24,7 +29,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
|
|||||||
/Mastodon/i,
|
/Mastodon/i,
|
||||||
/Bluesky/i,
|
/Bluesky/i,
|
||||||
/OpenGraph/i,
|
/OpenGraph/i,
|
||||||
/opengraph/i
|
/opengraph/i,
|
||||||
|
// Generic preview/scrape services commonly used in business
|
||||||
|
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the
|
||||||
|
// canonical lowercase substring; the /i flag handles case.
|
||||||
|
/LinkPreview/i,
|
||||||
|
/Slack-ImgProxy/i
|
||||||
];
|
];
|
||||||
|
|
||||||
function isSocialCrawler(userAgent) {
|
function isSocialCrawler(userAgent) {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the shared slug util extracted in #525 from the inline
|
||||||
|
* pipelines in adminEvents.js, events.js, v1/events.js, adminArchives.js.
|
||||||
|
*
|
||||||
|
* Two contracts to pin:
|
||||||
|
* 1. ASCII inputs produce byte-identical output to the previous
|
||||||
|
* inline pipelines, so existing event/archive slugs in the DB
|
||||||
|
* keep resolving via the same lookup path after the refactor.
|
||||||
|
* 2. Accented characters (Portuguese, German, French, Spanish) are
|
||||||
|
* transliterated to their ASCII bases (Decoração → decoracao)
|
||||||
|
* instead of being dropped (Decoração → decorao) as the legacy
|
||||||
|
* pipelines did — same fix as #502 for category slugs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { slugify } = require('../slug');
|
||||||
|
|
||||||
|
describe('slugify — ASCII parity with the legacy event-style pipeline', () => {
|
||||||
|
// Replays the exact transformation used by adminEvents.js before the
|
||||||
|
// refactor: lowercase → replace [^a-z0-9] with '-' → collapse → trim.
|
||||||
|
const legacy = (s) =>
|
||||||
|
String(s).toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
'Wedding 2026',
|
||||||
|
' Hello World ',
|
||||||
|
'birthday-party-42',
|
||||||
|
'event_with_underscores',
|
||||||
|
'CamelCase Event Name',
|
||||||
|
'',
|
||||||
|
'event.with.dots',
|
||||||
|
'event!@#$%^&*()chars',
|
||||||
|
'2026-06-12',
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(samples)('matches legacy output for ASCII input: %j', (input) => {
|
||||||
|
expect(slugify(input)).toBe(legacy(input));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slugify — accented characters (the #502 fix, now shared)', () => {
|
||||||
|
// The legacy pipeline produced f-mlia for "Família" because the í
|
||||||
|
// got replaced with '-' rather than being NFD-normalised to 'i'.
|
||||||
|
// These tests pin the corrected behaviour across the locales the
|
||||||
|
// app already ships in (de, es, fr, nl, pt, ru).
|
||||||
|
it.each([
|
||||||
|
['Decoração', 'decoracao'],
|
||||||
|
['Família', 'familia'],
|
||||||
|
['Recepção', 'recepcao'],
|
||||||
|
['Über uns', 'uber-uns'],
|
||||||
|
['Niño', 'nino'],
|
||||||
|
['Fête de famille', 'fete-de-famille'],
|
||||||
|
['L\'Évènement', 'l-evenement'],
|
||||||
|
['Crème Brûlée', 'creme-brulee'],
|
||||||
|
])('transliterates %j → %j', (input, expected) => {
|
||||||
|
expect(slugify(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK and other scripts without NFD decompositions still strip cleanly', () => {
|
||||||
|
// NFD doesn't decompose Chinese characters to ASCII, so they get
|
||||||
|
// dropped by the [^a-z0-9]+ replace. Output is sensible if not
|
||||||
|
// perfect — the surrounding ASCII tokens survive.
|
||||||
|
expect(slugify('Photo 混合 Test')).toBe('photo-test');
|
||||||
|
// Pure-CJK names collapse to empty after trim — caller's job to
|
||||||
|
// handle (typically by appending a uniqueness suffix).
|
||||||
|
expect(slugify('婚礼')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slugify — input edge cases', () => {
|
||||||
|
it('returns empty string for null / undefined / empty', () => {
|
||||||
|
expect(slugify(null)).toBe('');
|
||||||
|
expect(slugify(undefined)).toBe('');
|
||||||
|
expect(slugify('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces non-string input to string before slugifying', () => {
|
||||||
|
expect(slugify(2026)).toBe('2026');
|
||||||
|
expect(slugify(true)).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses any run of non-alphanumeric chars into a single dash', () => {
|
||||||
|
expect(slugify('a!@#$%b')).toBe('a-b');
|
||||||
|
expect(slugify('a b\t\nc')).toBe('a-b-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading and trailing dashes', () => {
|
||||||
|
expect(slugify('---hello---')).toBe('hello');
|
||||||
|
expect(slugify('!!!world!!!')).toBe('world');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* URL-safe slug generation shared across event, archive, and v1 upload
|
||||||
|
* routes (#525 follow-up to #502). Previously every caller had its own
|
||||||
|
* inline `name.toLowerCase().replace(/[^a-z0-9]/g, '-')` pipeline, each
|
||||||
|
* with the same latent bug: JS's `\w` and the ASCII alphanumeric class
|
||||||
|
* silently drop non-ASCII letters instead of transliterating them
|
||||||
|
* (`Decoração` → `decorao`, `Família` → `f-mlia`).
|
||||||
|
*
|
||||||
|
* Fix mirrors #502: NFD-normalize so accented characters split into a
|
||||||
|
* base letter + combining mark, then strip the combining-mark range
|
||||||
|
* (U+0300–U+036F) so the ASCII base survives. Single regex pass after
|
||||||
|
* that — `[^a-z0-9]+` collapses any run of non-alphanumerics into one
|
||||||
|
* dash, no separate collapse step needed.
|
||||||
|
*
|
||||||
|
* For pure-ASCII input the output is byte-identical to the previous
|
||||||
|
* inline pipelines, so existing slugs continue to round-trip cleanly
|
||||||
|
* via lookups; only new inserts with non-ASCII names start producing
|
||||||
|
* the corrected slugs.
|
||||||
|
*
|
||||||
|
* Not exported as the default category slug — `adminCategories.js`
|
||||||
|
* intentionally preserves underscores (the legacy category pipeline
|
||||||
|
* used `\w` not `[a-z0-9]`), so changing it here would silently shift
|
||||||
|
* "wedding_party" → "wedding-party" on new inserts. Categories keep
|
||||||
|
* their own pipeline as fixed in #502.
|
||||||
|
*/
|
||||||
|
function slugify(input) {
|
||||||
|
return String(input ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { slugify };
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
|
# Static HTML fallback for social-link previews (#521).
|
||||||
|
#
|
||||||
|
# Most link previews (WhatsApp, Facebook, Slack, etc.) hit the backend's
|
||||||
|
# per-event OG endpoint and get the actual event name + branding. Some
|
||||||
|
# third-party preview services and the WhatsApp Business API cache
|
||||||
|
# metadata with a non-crawler User-Agent and end up reading these static
|
||||||
|
# values instead. Set these to your brand so that fallback isn't generic
|
||||||
|
# "PicPeak - Photo Sharing Platform".
|
||||||
|
#
|
||||||
|
# These are baked into index.html at build time, so they take effect on
|
||||||
|
# the next `npm run build` / docker build. Live admin Branding settings
|
||||||
|
# do NOT propagate here — for that, use the per-event OG endpoint, which
|
||||||
|
# always serves the live branded preview.
|
||||||
|
VITE_DEFAULT_TITLE=PicPeak
|
||||||
|
VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||||
|
|
||||||
# Backend API URL
|
# Backend API URL
|
||||||
# For local development with Docker:
|
# For local development with Docker:
|
||||||
VITE_API_URL=http://localhost:3001/api
|
VITE_API_URL=http://localhost:3001/api
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Production Environment Configuration
|
# Production Environment Configuration
|
||||||
# When running behind a reverse proxy like Traefik, use relative URLs
|
# When running behind a reverse proxy like Traefik, use relative URLs
|
||||||
|
|
||||||
|
# Static HTML fallback for social-link previews (#521).
|
||||||
|
# Override these with your brand so previews that hit the static
|
||||||
|
# index.html (vs the per-event OG endpoint) aren't generic.
|
||||||
|
VITE_DEFAULT_TITLE=PicPeak
|
||||||
|
VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||||
|
|
||||||
# Backend API URL
|
# Backend API URL
|
||||||
# For production behind reverse proxy, use relative URL:
|
# For production behind reverse proxy, use relative URL:
|
||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
|
|||||||
+27
-1
@@ -4,7 +4,33 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>PicPeak - Photo Sharing Platform</title>
|
<!--
|
||||||
|
Static fallback title + Open Graph defaults (#521).
|
||||||
|
|
||||||
|
The runtime SPA updates these once React loads, and social-crawler
|
||||||
|
User-Agents hitting /gallery/<slug> get a per-event rich preview
|
||||||
|
served by backend's galleryOgService instead of this static shell.
|
||||||
|
|
||||||
|
But the third path — link previews fetched by WhatsApp Business
|
||||||
|
API, Twilio, LinkPreview, or any service that caches metadata
|
||||||
|
with a non-crawler UA — gets *this* HTML as-is. Defaulting the
|
||||||
|
title to "PicPeak - Photo Sharing Platform" left every such
|
||||||
|
preview looking unbranded for self-hosted installs.
|
||||||
|
|
||||||
|
Self-hosters set VITE_DEFAULT_TITLE / VITE_DEFAULT_DESCRIPTION
|
||||||
|
at build time (see .env.example) to bake their brand into this
|
||||||
|
fallback. Default values keep the upstream-image behaviour for
|
||||||
|
anyone who doesn't override them.
|
||||||
|
-->
|
||||||
|
<title>%VITE_DEFAULT_TITLE%</title>
|
||||||
|
<meta name="description" content="%VITE_DEFAULT_DESCRIPTION%" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="%VITE_DEFAULT_TITLE%" />
|
||||||
|
<meta property="og:title" content="%VITE_DEFAULT_TITLE%" />
|
||||||
|
<meta property="og:description" content="%VITE_DEFAULT_DESCRIPTION%" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="%VITE_DEFAULT_TITLE%" />
|
||||||
|
<meta name="twitter:description" content="%VITE_DEFAULT_DESCRIPTION%" />
|
||||||
|
|
||||||
<!-- Pre-React theme bootstrap (#358).
|
<!-- Pre-React theme bootstrap (#358).
|
||||||
The browser may paint the very first frame before our inline
|
The browser may paint the very first frame before our inline
|
||||||
|
|||||||
+5
-1
@@ -184,7 +184,11 @@ server {
|
|||||||
# meta tags never reach them. Route those UAs to backend's /og handler
|
# meta tags never reach them. Route those UAs to backend's /og handler
|
||||||
# via internal rewrite; humans fall through to the SPA via try_files.
|
# via internal rewrite; humans fall through to the SPA via try_files.
|
||||||
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
||||||
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
|
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
|
||||||
|
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
|
||||||
|
# and LinkPreview / Slack-ImgProxy added in #521 to catch
|
||||||
|
# business-API preview fetchers that aren't the main WhatsApp app.
|
||||||
|
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
|
||||||
rewrite ^ /og/gallery/$gallery_slug last;
|
rewrite ^ /og/gallery/$gallery_slug last;
|
||||||
}
|
}
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -87,11 +87,17 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex items-center gap-2 px-2 sm:px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
// On <sm the language *name* is hidden — the Globe + flag pair
|
||||||
|
// is enough recognition on its own and stops this control from
|
||||||
|
// pushing into the company-name title on narrow mobile widths
|
||||||
|
// (#523). Full name stays on sm+ where there's room.
|
||||||
|
aria-label={currentLanguage.name}
|
||||||
|
title={currentLanguage.name}
|
||||||
>
|
>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
<currentLanguage.Flag className="w-5 h-5" />
|
<currentLanguage.Flag className="w-5 h-5" />
|
||||||
<span>{currentLanguage.name}</span>
|
<span className="hidden sm:inline">{currentLanguage.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
feedback_enabled?: boolean;
|
feedback_enabled?: boolean;
|
||||||
allow_likes?: boolean;
|
allow_likes?: boolean;
|
||||||
allow_ratings?: boolean;
|
allow_ratings?: boolean;
|
||||||
|
allow_comments?: boolean;
|
||||||
require_name_email?: boolean;
|
require_name_email?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [myLiked, setMyLiked] = useState<boolean>(false);
|
const [myLiked, setMyLiked] = useState<boolean>(false);
|
||||||
@@ -684,8 +685,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback button with indicator */}
|
{/* Feedback button with indicator. Gated on allow_comments
|
||||||
{feedbackEnabled && (
|
because likes/ratings already have their own dedicated
|
||||||
|
toolbar buttons above — this MessageSquare button only
|
||||||
|
opens the comments panel, so it has nothing to do when
|
||||||
|
comments are off (#518). */}
|
||||||
|
{feedbackEnabled && feedbackSettings?.allow_comments && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowFeedback(!showFeedback);
|
setShowFeedback(!showFeedback);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface EventSettings {
|
|||||||
event_require_event_date: boolean;
|
event_require_event_date: boolean;
|
||||||
event_require_expiration: boolean;
|
event_require_expiration: boolean;
|
||||||
event_default_require_password: boolean;
|
event_default_require_password: boolean;
|
||||||
|
event_default_feedback_enabled: boolean;
|
||||||
gallery_show_filter_bar: boolean;
|
gallery_show_filter_bar: boolean;
|
||||||
event_phone_field_enabled: boolean;
|
event_phone_field_enabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ export function useSettingsState() {
|
|||||||
event_require_event_date: true,
|
event_require_event_date: true,
|
||||||
event_require_expiration: true,
|
event_require_expiration: true,
|
||||||
event_default_require_password: true,
|
event_default_require_password: true,
|
||||||
|
event_default_feedback_enabled: false,
|
||||||
gallery_show_filter_bar: true,
|
gallery_show_filter_bar: true,
|
||||||
event_phone_field_enabled: false
|
event_phone_field_enabled: false
|
||||||
});
|
});
|
||||||
@@ -220,6 +222,7 @@ export function useSettingsState() {
|
|||||||
event_require_event_date: toBoolean(settings.event_require_event_date, true),
|
event_require_event_date: toBoolean(settings.event_require_event_date, true),
|
||||||
event_require_expiration: toBoolean(settings.event_require_expiration, true),
|
event_require_expiration: toBoolean(settings.event_require_expiration, true),
|
||||||
event_default_require_password: toBoolean(settings.event_default_require_password, true),
|
event_default_require_password: toBoolean(settings.event_default_require_password, true),
|
||||||
|
event_default_feedback_enabled: toBoolean(settings.event_default_feedback_enabled, false),
|
||||||
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
|
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
|
||||||
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
|
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,6 +169,25 @@ export const EventsTab: React.FC<EventsTabProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eventSettings.event_default_feedback_enabled}
|
||||||
|
onChange={(e) => setEventSettings(prev => ({ ...prev, event_default_feedback_enabled: e.target.checked }))}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t('settings.events.defaultFeedbackEnabled', 'Enable Guest Feedback by default')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
{t('settings.events.defaultFeedbackEnabledHelp', 'Pre-check "Guest Feedback" when creating new events. Individual feedback options (likes, ratings, comments) can still be customised per event.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-start gap-3">
|
<label className="flex items-start gap-3">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -246,6 +246,25 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [publicSettings]);
|
}, [publicSettings]);
|
||||||
|
|
||||||
|
// Honour the global "Enable Guest Feedback by default" admin setting (#520).
|
||||||
|
// Same one-shot apply pattern as require_password above — only seeds the
|
||||||
|
// master toggle. The sub-toggles (likes / ratings / comments) keep their
|
||||||
|
// hard-coded true defaults so a flipped master immediately gives sensible
|
||||||
|
// behaviour without a second admin setting to manage.
|
||||||
|
const feedbackEnabledDefaultApplied = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (feedbackEnabledDefaultApplied.current) return;
|
||||||
|
if (publicSettings?.event_default_feedback_enabled === undefined) return;
|
||||||
|
feedbackEnabledDefaultApplied.current = true;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
feedback_settings: {
|
||||||
|
...prev.feedback_settings,
|
||||||
|
feedback_enabled: publicSettings.event_default_feedback_enabled === true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [publicSettings]);
|
||||||
|
|
||||||
// Apply the global Branding default theme on first load so admins who set a
|
// Apply the global Branding default theme on first load so admins who set a
|
||||||
// site-wide default in Branding actually see it on new events (#323).
|
// site-wide default in Branding actually see it on new events (#323).
|
||||||
// This is the "always inherit colours from Branding" guarantee — every new
|
// This is the "always inherit colours from Branding" guarantee — every new
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface PublicSettings {
|
|||||||
event_require_event_date?: boolean;
|
event_require_event_date?: boolean;
|
||||||
event_require_expiration?: boolean;
|
event_require_expiration?: boolean;
|
||||||
event_default_require_password?: boolean;
|
event_default_require_password?: boolean;
|
||||||
|
event_default_feedback_enabled?: boolean;
|
||||||
gallery_show_filter_bar?: boolean;
|
gallery_show_filter_bar?: boolean;
|
||||||
event_phone_field_enabled?: boolean;
|
event_phone_field_enabled?: boolean;
|
||||||
// SEO meta tags (consumed by RobotsMetaTags)
|
// SEO meta tags (consumed by RobotsMetaTags)
|
||||||
|
|||||||
+23
-2
@@ -1,13 +1,34 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import type { UserConfig as VitestUserConfig } from 'vitest/config'
|
import type { UserConfig as VitestUserConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// Inject defaults for the %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION%
|
||||||
|
// placeholders in index.html when the env vars aren't set (#521). Without
|
||||||
|
// this, Vite would leave the literal "%VITE_DEFAULT_TITLE%" string in the
|
||||||
|
// built HTML, breaking the link-preview fallback we're trying to create.
|
||||||
|
//
|
||||||
|
// Self-hosters override by exporting the env vars at build time
|
||||||
|
// (typical Docker build pattern: --build-arg VITE_DEFAULT_TITLE="My Brand").
|
||||||
|
function htmlTitleDefaults(mode: string) {
|
||||||
|
const env = loadEnv(mode, process.cwd(), 'VITE_')
|
||||||
|
const title = env.VITE_DEFAULT_TITLE || 'PicPeak'
|
||||||
|
const description = env.VITE_DEFAULT_DESCRIPTION || 'Photo gallery shared with PicPeak.'
|
||||||
|
return {
|
||||||
|
name: 'html-title-defaults',
|
||||||
|
transformIndexHtml(html: string) {
|
||||||
|
return html
|
||||||
|
.replaceAll('%VITE_DEFAULT_TITLE%', title)
|
||||||
|
.replaceAll('%VITE_DEFAULT_DESCRIPTION%', description)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
const config: VitestUserConfig = {
|
const config: VitestUserConfig = {
|
||||||
plugins: [react()],
|
plugins: [react(), htmlTitleDefaults(process.env.NODE_ENV || 'production')],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
Reference in New Issue
Block a user