feat(gallery): per-event toggle to hide the logo on the password page (#894) (#928)

* feat(gallery): per-event toggle to hide the logo on the password page (#894)

* fix(admin): harden login_logo_visible coercion for SQLite + string booleans (#894)

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-31 08:56:48 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 926a4a540d
commit 08ff9f20e7
12 changed files with 122 additions and 11 deletions
@@ -201,6 +201,34 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(400);
});
// #894 — per-event password-page logo toggle: false hides, null
// restores the default (show).
it('stores login_logo_visible: false and clears it back to NULL', async () => {
const id = await insertEvent(db, adminId);
const hide = await auth(request(app).put(`/api/admin/events/${id}`)).send({
login_logo_visible: false,
});
expect(hide.status).toBe(200);
let row = await db('events').where({ id }).first();
expect([false, 0]).toContain(row.login_logo_visible);
const clear = await auth(request(app).put(`/api/admin/events/${id}`)).send({
login_logo_visible: null,
});
expect(clear.status).toBe(200);
row = await db('events').where({ id }).first();
expect(row.login_logo_visible).toBeNull();
// The string "false" passes isBoolean() validation — it must be
// parsed, not treated as a truthy string (would store 1 = show).
const hideStr = await auth(request(app).put(`/api/admin/events/${id}`)).send({
login_logo_visible: 'false',
});
expect(hideStr.status).toBe(200);
row = await db('events').where({ id }).first();
expect([false, 0]).toContain(row.login_logo_visible);
});
});
describe('DELETE /:id', () => {
@@ -0,0 +1,21 @@
/**
* Migration 166: per-event toggle to hide the branding logo on the
* gallery password page (#894).
*
* NULL (the default) keeps today's behaviour — the global branding logo is
* shown above the password form. Only an explicit `false` hides it for
* that gallery; the admin login page and other surfaces are unaffected.
*/
exports.up = async function (knex) {
if (await knex.schema.hasColumn('events', 'login_logo_visible')) return;
await knex.schema.alterTable('events', (t) => {
t.boolean('login_logo_visible').nullable();
});
};
exports.down = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'login_logo_visible'))) return;
await knex.schema.alterTable('events', (t) => {
t.dropColumn('login_logo_visible');
});
};
+13
View File
@@ -1090,6 +1090,7 @@ module.exports = (router) => {
hero_logo_visible: source.hero_logo_visible,
hero_logo_size: source.hero_logo_size,
hero_logo_position: source.hero_logo_position,
login_logo_visible: source.login_logo_visible,
header_style: source.header_style || 'standard',
hero_divider_style: source.hero_divider_style || 'wave',
hero_image_anchor: source.hero_image_anchor || 'center',
@@ -1237,6 +1238,8 @@ module.exports = (router) => {
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Password-page logo toggle (#894). null = default (show).
body('login_logo_visible').optional({ nullable: true }).isBoolean(),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
@@ -1452,6 +1455,16 @@ module.exports = (router) => {
: formatBoolean(updates.hero_logo_visible);
}
// Password-page logo toggle (#894): null passes through; other
// accepted representations ("false", 0, …) are parsed before the
// DB formatting — formatBoolean alone would store the truthy
// string "false" as 1.
if (Object.prototype.hasOwnProperty.call(updates, 'login_logo_visible')) {
updates.login_logo_visible = updates.login_logo_visible === null
? null
: formatBoolean(parseBooleanInput(updates.login_logo_visible, true));
}
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
// SQLite stores 0/1 and Postgres stores boolean true/false.
if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) {
+4
View File
@@ -221,6 +221,7 @@ router.get('/:slug/info', async (req, res) => {
'hero_logo_size',
'hero_logo_position',
'hero_logo_url',
'login_logo_visible',
'header_style',
'hero_divider_style',
'hero_image_anchor',
@@ -290,6 +291,9 @@ router.get('/:slug/info', async (req, res) => {
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
// #894: only an explicit false hides the logo on the password page;
// NULL keeps the default (show).
login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'),
// #756: NULL per-event size inherits the global branding_logo_size.
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
+3
View File
@@ -1149,6 +1149,9 @@
"heroLogoInherit": "Branding-Standard verwenden",
"heroLogoShow": "Immer anzeigen",
"heroLogoHide": "Immer ausblenden",
"loginLogoVisible": "Logo auf der Passwort-Seite anzeigen",
"loginLogoShow": "Anzeigen (Standard)",
"loginLogoHide": "Ausblenden",
"heroLogoSize": "Logo-Größe",
"heroLogoSizeSmall": "Klein",
"heroLogoSizeMedium": "Mittel",
+3
View File
@@ -692,6 +692,9 @@
"heroLogoInherit": "Use branding default",
"heroLogoShow": "Always show",
"heroLogoHide": "Always hide",
"loginLogoVisible": "Display logo on password page",
"loginLogoShow": "Show (default)",
"loginLogoHide": "Hide",
"heroLogoSize": "Logo Size",
"heroLogoSizeSmall": "Small",
"heroLogoSizeMedium": "Medium",
+2 -2
View File
@@ -110,8 +110,8 @@ export const ClientAccessPage: React.FC = () => {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo */}
{brandLogo && (
{/* Logo — hidden when the admin turned it off for this gallery (#894) */}
{brandLogo && galleryInfo.login_logo_visible !== false && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(brandLogo)}
+11 -9
View File
@@ -405,16 +405,18 @@ export const GalleryPage: React.FC = () => {
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{/* Logo/Header */}
{/* Logo/Header. The logo can be hidden per gallery (#894). */}
<div className="text-center mb-4 sm:mb-6">
<img
src={settingsData?.branding_logo_url ?
buildResourceUrl(settingsData.branding_logo_url) :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
{galleryInfo?.login_logo_visible !== false && (
<img
src={settingsData?.branding_logo_url ?
buildResourceUrl(settingsData.branding_logo_url) :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
)}
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-2 px-2" style={{ color: 'var(--color-primary, #5C8762)' }}>
{galleryInfo?.event_name}
</h1>
@@ -342,6 +342,10 @@ export const EventDetailsPage: React.FC = () => {
// Preserve null = "inherit global size" (#756) — don't collapse to medium.
hero_logo_size: event.hero_logo_size ?? null,
hero_logo_position: event.hero_logo_position || 'top',
// #894: null = default (show); only false hides the password-page logo.
// Boolean() folds SQLite's 0/1 into real booleans so the edit form's
// strict `=== false` check reads a persisted hide correctly.
login_logo_visible: event.login_logo_visible == null ? null : Boolean(event.login_logo_visible),
// Hero image anchor position (#162)
hero_image_anchor: event.hero_image_anchor || 'center',
// Photo cap
@@ -475,6 +479,7 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_visible: editForm.hero_logo_visible,
hero_logo_size: editForm.hero_logo_size,
hero_logo_position: editForm.hero_logo_position,
login_logo_visible: editForm.login_logo_visible,
// Hero image anchor position (#162)
hero_image_anchor: editForm.hero_image_anchor,
// Photo cap
@@ -757,6 +757,26 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
</>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1 flex items-center gap-1">
<Image className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
{t('events.loginLogoVisible', 'Display logo on password page')}
</label>
<select
// #894: two-state — null keeps the default (show), false
// hides the branding logo on this gallery's password page.
value={editForm.login_logo_visible === false ? 'hide' : 'show'}
onChange={(e) => setEditForm(prev => ({
...prev,
login_logo_visible: e.target.value === 'hide' ? false : null
}))}
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
>
<option value="show">{t('events.loginLogoShow', 'Show (default)')}</option>
<option value="hide">{t('events.loginLogoHide', 'Hide')}</option>
</select>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
</p>
@@ -31,6 +31,8 @@ export type EditFormState = {
hero_logo_visible: boolean | null;
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge' | null;
hero_logo_position: 'top' | 'center' | 'bottom';
// #894: null = default (show); false hides the logo on the password page.
login_logo_visible: boolean | null;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor: string;
// Photo cap
@@ -81,6 +83,7 @@ export const INITIAL_EDIT_FORM: EditFormState = {
hero_logo_visible: null,
hero_logo_size: null,
hero_logo_position: 'top',
login_logo_visible: null,
// Hero image anchor position (#162)
hero_image_anchor: 'center',
// Photo cap
+9
View File
@@ -51,6 +51,9 @@ export interface Event {
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
hero_logo_position?: 'top' | 'center' | 'bottom';
hero_logo_url?: string | null;
// Hide the branding logo on the gallery password page (#894).
// null = default (show); only an explicit false hides it.
login_logo_visible?: boolean | null;
// Per-event opt-in for using the hero photo as the social-share
// preview image (#474). When false, og:image falls back to the
// brand logo. Defaults false on existing rows so no admin's hero
@@ -105,6 +108,9 @@ export interface GalleryInfo {
requires_password?: boolean;
color_theme?: string;
default_photo_sort?: string;
// Resolved server-side (#894): false only when the admin hid the logo
// on this gallery's password page.
login_logo_visible?: boolean;
}
export interface Photo {
@@ -198,6 +204,9 @@ export interface GalleryData {
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
hero_logo_position?: 'top' | 'center' | 'bottom';
hero_logo_url?: string | null;
// Resolved server-side (#894): false only when the admin hid the
// logo on this gallery's password page.
login_logo_visible?: boolean;
// Header style settings (decoupled from layout)
header_style?: 'hero' | 'standard' | 'minimal' | 'none';
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';