* 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:
co-authored by
Paul Nothaft
parent
926a4a540d
commit
08ff9f20e7
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user