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.