From 1bf19a7caf45b7f800b3650b2cd2365ea89cb169 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Mon, 10 Aug 2026 08:27:28 +0200
Subject: [PATCH] fix(branding): route the gallery footer through
(#1008)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #1003.
#999 centralised the attribution so branding_hide_powered_by is honoured
everywhere, but GalleryLayout kept its own inline guard. The gallery footer
therefore still flashed — it kept `!brandingSettings?.hide_powered_by`, where
undefined is falsy, so a white-labelled instance briefly showed the attribution
on first paint, on the surface a white-label customer is most likely to see.
And there were two implementations of one rule, which is the bug class #999
existed to close.
The footer appends the attribution to its copyright line inside an existing
, so a straight swap would nest a
in a
. Added an inline variant
rendering a that carries the leading ' | ' itself: the separator belongs
to the component, since a caller placing its own would have to repeat the
visibility guard to avoid leaving a dangling separator when the attribution is
hidden.
No extra request — GalleryView already uses usePublicSettings(), the same hook
and react-query key, so the cache is shared. The footer also picks up
common.poweredBy, so it is translated rather than hardcoded English.
Removes the now-unread hide_powered_by from GalleryLayout's prop type and the
mapping feeding it in GalleryView.
Four cases cover the variant — span not paragraph, separator present, separator
hidden with the attribution when white-labelled, hidden while loading. Each was
checked against the pre-fix shape: rendering a or moving the separator out
breaks one.
---
frontend/src/components/common/PoweredBy.tsx | 24 +++++++++++--
.../common/__tests__/PoweredBy.test.tsx | 34 +++++++++++++++++++
.../src/components/gallery/GalleryLayout.tsx | 7 ++--
.../src/components/gallery/GalleryView.tsx | 1 -
4 files changed, 58 insertions(+), 8 deletions(-)
diff --git a/frontend/src/components/common/PoweredBy.tsx b/frontend/src/components/common/PoweredBy.tsx
index 68b3fabf..aac25e00 100644
--- a/frontend/src/components/common/PoweredBy.tsx
+++ b/frontend/src/components/common/PoweredBy.tsx
@@ -5,11 +5,21 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
interface PoweredByProps {
className?: string;
style?: React.CSSProperties;
+ /**
+ * Render inside an existing paragraph — as ` | Powered by PicPeak` in a
+ * `` — rather than as its own ``. The gallery footer appends the
+ * attribution to its copyright line, and a `
` nested in a `
` is invalid.
+ *
+ * The separator belongs to the component on purpose: a caller placing its own
+ * ` | ` would have to repeat the visibility guard to avoid leaving a dangling
+ * separator behind, and repeating that guard is what #999 was fixing.
+ */
+ inline?: boolean;
}
// "Powered by PicPeak" footer/login attribution. Reads the setting itself (like
// DynamicFavicon) so callers don't repeat the guard; hidden for white-label.
-export const PoweredBy: React.FC = ({ className, style }) => {
+export const PoweredBy: React.FC = ({ className, style, inline = false }) => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
@@ -17,9 +27,19 @@ export const PoweredBy: React.FC = ({ className, style }) => {
// instance flashes the attribution before branding_hide_powered_by resolves.
if (!settings || settings.branding_hide_powered_by) return null;
+ const label = (
+ <>
+ {t('common.poweredBy')} PicPeak
+ >
+ );
+
+ if (inline) {
+ return | {label};
+ }
+
return (
- {t('common.poweredBy')} PicPeak
+ {label}
);
};
diff --git a/frontend/src/components/common/__tests__/PoweredBy.test.tsx b/frontend/src/components/common/__tests__/PoweredBy.test.tsx
index c67eea26..6599191d 100644
--- a/frontend/src/components/common/__tests__/PoweredBy.test.tsx
+++ b/frontend/src/components/common/__tests__/PoweredBy.test.tsx
@@ -60,4 +60,38 @@ describe('PoweredBy', () => {
expect(paragraph).toHaveClass('text-xs', 'mt-2');
expect(paragraph).toHaveStyle({ opacity: '0.5' });
});
+
+ // The gallery footer appends the attribution to its copyright line, inside an
+ // existing . A nested
is invalid HTML, so that call site needs a span
+ // and the leading separator (#1003).
+ describe('inline variant', () => {
+ it('renders a span, not a paragraph, so it can live inside one', () => {
+ setSettings({});
+ const { container } = render();
+ expect(screen.getByText('PicPeak').closest('p')).toBeNull();
+ expect(container.querySelector('span')).not.toBeNull();
+ });
+
+ it('carries its own separator', () => {
+ setSettings({});
+ const { container } = render();
+ expect(container.textContent).toContain('| Powered by');
+ });
+
+ it('hides the separator along with the attribution when white-labelled', () => {
+ // The separator has to be inside the component: a caller rendering its
+ // own " | " would need to repeat the visibility guard, and would leave a
+ // dangling separator the moment it drifted.
+ setSettings({ branding_hide_powered_by: true });
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ expect(container.textContent).not.toContain('|');
+ });
+
+ it('hides while settings are still loading, like the block variant', () => {
+ setSettings(undefined);
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
});
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx
index 60d8e064..da4dd466 100644
--- a/frontend/src/components/gallery/GalleryLayout.tsx
+++ b/frontend/src/components/gallery/GalleryLayout.tsx
@@ -5,7 +5,7 @@ import { Calendar, Clock, Download, LogOut, Facebook, Instagram, Twitter, Youtub
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
-import { Button, MarkdownContent } from '../common';
+import { Button, MarkdownContent, PoweredBy } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
@@ -48,7 +48,6 @@ interface GalleryLayoutProps {
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
- hide_powered_by?: boolean;
// Footer overhaul (#441 + #440). Empty strings hide each socials icon.
facebook_url?: string;
instagram_url?: string;
@@ -702,9 +701,7 @@ export const GalleryLayout: React.FC = ({
)}
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
- {!brandingSettings?.hide_powered_by && (
- <> | Powered by PicPeak>
- )}
+
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index 821ef102..5cc75683 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -316,7 +316,6 @@ export const GalleryView: React.FC = ({ slug, event }) => {
logo_display_header: settingsData.branding_logo_display_header !== false,
logo_display_hero: settingsData.branding_logo_display_hero !== false,
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
- hide_powered_by: settingsData.branding_hide_powered_by === true,
// Footer overhaul (#441 + #440). All five socials are optional;
// empty strings → that icon is hidden. promo_markdown is the
// global default; per-event override happens in GalleryLayout.