Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export function useDesignDraft() {
copy: view.feedback.copy,
logo: view.logo,
statusColours: view.statusColours,
bandImage: view.bandImage,
}
}, [design])

Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/public/__tests__/portal-band.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,38 @@ describe('PortalBand', () => {
expect(band.className).toContain('[background:var(--qb-band-bg-narrow,var(--qb-band-bg))]')
expect(band.className).toContain('sm:[background:var(--qb-band-bg)]')
})

it('sets a side image beside the words', () => {
root.portalDesign = {
...view({ title: { en: 'Ideas' }, subtitle: {} }),
bandImage: { url: '/api/storage/portal-images/room.jpg', place: 'side' },
}
mount()
const band = document.querySelector('[data-qb-part=band]')!
const image = band.querySelector('img[data-qb-part=band-image]')!
expect(image.getAttribute('src')).toBe('/api/storage/portal-images/room.jpg')
expect(band.hasAttribute('data-qb-band-image')).toBe(false)
})

it('puts a cover or pattern image behind the words, from storage only', () => {
root.portalDesign = {
...view({ title: { en: 'Ideas' }, subtitle: {} }),
bandImage: { url: '/api/storage/portal-images/leaves.png', place: 'pattern' },
}
mount()
let band = document.querySelector('[data-qb-part=band]') as HTMLElement
expect(band.getAttribute('data-qb-band-image')).toBe('pattern')
expect(band.style.getPropertyValue('--qb-band-image')).toBe(
'url("/api/storage/portal-images/leaves.png")'
)
cleanup()
root.portalDesign = {
...view({ title: { en: 'Ideas' }, subtitle: {} }),
bandImage: { url: 'https://example.com/x.png");}body{', place: 'cover' },
}
mount()
band = document.querySelector('[data-qb-part=band]') as HTMLElement
expect(band.hasAttribute('data-qb-band-image')).toBe(false)
expect(band.getAttribute('style')).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ describe('a draft design read from the preview message', () => {
expect(message({ statusColours: ['red'] }).statusColours).toBeNull()
})

it('keeps a band image only from storage, in a place it knows', () => {
expect(
message({ bandImage: { url: '/api/storage/portal-images/a.jpg', place: 'edge' } }).bandImage
).toEqual({ url: '/api/storage/portal-images/a.jpg', place: 'edge' })
expect(
message({ bandImage: { url: 'https://x.example/a.jpg', place: 'edge' } }).bandImage
).toBeNull()
expect(
message({ bandImage: { url: '/api/storage/a.jpg', place: 'float' } }).bandImage
).toBeNull()
})

it('keeps a headline’s accent only in a shape it knows', () => {
const title = { en: 'Make it better' }
expect(
Expand Down
39 changes: 36 additions & 3 deletions apps/web/src/components/public/portal-band.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useIntl } from 'react-intl'
import { useRouteContext } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { cn } from '@/lib/shared/utils'
import { usePreviewHeadline, usePreviewHome } from './preview-draft-context'
import { usePreviewBandImage, usePreviewHeadline, usePreviewHome } from './preview-draft-context'

/** Text in the visitor's language, else its base language, else English, else any. */
export function textForLocale(
Expand All @@ -14,6 +14,20 @@ export function textForLocale(
return text[locale] || text[base] || text.en || Object.values(text).find(Boolean)
}

/** A band image comes from the workspace's own storage. */
const STORED_FILE = /^\/api\/storage\/[\w./-]+$/

/**
* The band's image behind its words: along the bottom (on wider screens),
* veiled by the band's colour so the words keep their contrast, or tiled
* under a lighter veil.
*/
const IMAGE_FILL = [
'sm:[&[data-qb-band-image=edge]]:[background:var(--qb-band-image)_center_bottom/100%_auto_no-repeat,var(--qb-band-bg)] sm:[&[data-qb-band-image=edge]]:pb-44',
'[&[data-qb-band-image=cover]]:[background:linear-gradient(var(--qb-band-scrim),var(--qb-band-scrim)),var(--qb-band-image)_center/cover_no-repeat,var(--qb-band-bg)]',
'[&[data-qb-band-image=pattern]]:[background:linear-gradient(var(--qb-band-scrim-soft),var(--qb-band-scrim-soft)),var(--qb-band-image)_0_0/280px_auto_repeat,var(--qb-band-bg)]',
].join(' ')

/** The band's colour: plain on phones where the design asks, as a mesh crosses the words there. */
const BAND_FILL =
'[background:var(--qb-band-bg-narrow,var(--qb-band-bg))] sm:[background:var(--qb-band-bg)]'
Expand Down Expand Up @@ -64,6 +78,7 @@ export function PortalBand({
})
const draftHeadline = usePreviewHeadline()
const draftHome = usePreviewHome()
const draftImage = usePreviewBandImage()
if (design?.engine !== 'v2') return null
const headline =
page === 'home'
Expand All @@ -75,13 +90,21 @@ export function PortalBand({
const subtitle = textForLocale(headline?.subtitle, intl.locale)
const phrase = textForLocale(headline?.accent?.text, intl.locale)
if (!title && !subtitle && !children) return null
const shown = draftImage !== undefined ? draftImage : design.bandImage
const image = shown && STORED_FILE.test(shown.url) ? shown : null
const side = image?.place === 'side'
return (
<section
data-qb-section="band"
data-qb-part="band"
data-qb-band-image={image && !side ? image.place : undefined}
style={
image && !side ? ({ '--qb-band-image': `url("${image.url}")` } as CSSProperties) : undefined
}
className={cn(
'portal-band py-10 [[data-qb-band=none]_&]:py-0',
BAND_FILL,
IMAGE_FILL,
// Inset: the band's colour moves onto a rounded panel within the page.
'[[data-qb-band-shape=inset]_&]:![background:transparent] [[data-qb-band-shape=inset]_&]:py-3'
)}
Expand All @@ -91,7 +114,9 @@ export function PortalBand({
'mx-auto w-full max-w-[var(--qb-page-max,72rem)] px-4 pt-6 sm:px-6 [[data-qb-band=none]_&]:pb-0',
'[[data-qb-band-align=centre]_&]:text-center',
'[[data-qb-band-shape=inset]_&]:max-w-[calc(var(--qb-page-max,72rem)-2rem)] [[data-qb-band-shape=inset]_&]:rounded-[calc(var(--qb-radius-panel,0.75rem)*2.5)] [[data-qb-band-shape=inset]_&]:px-6 [[data-qb-band-shape=inset]_&]:py-10 sm:[[data-qb-band-shape=inset]_&]:px-12 sm:[[data-qb-band-shape=inset]_&]:py-14',
'[[data-qb-band-shape=inset]_&]:[background:var(--qb-band-bg-narrow,var(--qb-band-bg))] sm:[[data-qb-band-shape=inset]_&]:[background:var(--qb-band-bg)]'
'[[data-qb-band-shape=inset]_&]:[background:var(--qb-band-bg-narrow,var(--qb-band-bg))] sm:[[data-qb-band-shape=inset]_&]:[background:var(--qb-band-bg)]',
// A side image sits beside the words, and under them on phones.
side && 'md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)] md:items-center md:gap-10'
)}
>
{/* Panel: the words sit on a block of the second colour, in its own text colours. */}
Expand All @@ -115,6 +140,14 @@ export function PortalBand({
</div>
)}
</div>
{side && image && (
<img
src={image.url}
alt=""
data-qb-part="band-image"
className="mt-6 max-h-48 w-full rounded-[var(--qb-radius-panel,0.75rem)] object-cover md:mt-0 md:h-full md:max-h-[22rem]"
/>
)}
</div>
</section>
)
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/components/public/portal-preview-design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* shared code into a chunk of its own.
*/

import type { DesignLogo, PortalWords } from '@/lib/shared/design/view'
import type { BandImage, DesignLogo, PortalWords } from '@/lib/shared/design/view'

type TextByLocale = Record<string, string>

Expand Down Expand Up @@ -41,6 +41,17 @@ export interface DesignPreview {
logo: DesignLogo | null
/** The logo's colours status markers take, when the draft asks for them. */
statusColours: string[] | null
bandImage: BandImage | null
}

const IMAGE_PLACES = new Set(['side', 'edge', 'cover', 'pattern'])

function readBandImage(value: unknown): BandImage | null {
if (!value || typeof value !== 'object') return null
const { url, place } = value as Record<string, unknown>
return typeof url === 'string' && STORED_FILE.test(url) && IMAGE_PLACES.has(place as string)
? { url, place: place as BandImage['place'] }
: null
}

/** A colour as a design stores it: hex, or oklch(). */
Expand Down Expand Up @@ -157,8 +168,19 @@ const DESIGN_VALUE = /^[a-z_]+$/
/** The message's design, keeping only the structural attributes a design sets. */
export function readDesignPreview(value: unknown): DesignPreview | null {
if (!value || typeof value !== 'object') return null
const { css, attributes, fontIds, headline, home, nav, vocabulary, copy, logo, statusColours } =
value as Record<string, unknown>
const {
css,
attributes,
fontIds,
headline,
home,
nav,
vocabulary,
copy,
logo,
statusColours,
bandImage,
} = value as Record<string, unknown>
if (typeof css !== 'string') return null
const safe = Object.fromEntries(
Object.entries((attributes as Record<string, unknown>) ?? {}).filter(
Expand All @@ -177,6 +199,7 @@ export function readDesignPreview(value: unknown): DesignPreview | null {
copy: readCopy(copy),
logo: readLogo(logo),
statusColours: readStatusColours(statusColours),
bandImage: readBandImage(bandImage),
}
}

Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/components/public/portal-preview-listener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PreviewDraftProvider,
PreviewAttributesContext,
PreviewLogoContext,
PreviewBandImageContext,
PreviewHeadlineContext,
PreviewPagesContext,
FeedCopyContext,
Expand Down Expand Up @@ -153,13 +154,15 @@ export function PortalPreviewProvider({
<PreviewAttributesContext.Provider value={design?.attributes}>
<PreviewLogoContext.Provider value={design?.logo ?? undefined}>
<BrandStatusColours colours={design?.statusColours}>
<DraftCopy copy={design?.copy}>
{design?.words ? (
<PortalWordsIntl words={design.words}>{children}</PortalWordsIntl>
) : (
children
)}
</DraftCopy>
<PreviewBandImageContext.Provider value={design ? design.bandImage : undefined}>
<DraftCopy copy={design?.copy}>
{design?.words ? (
<PortalWordsIntl words={design.words}>{children}</PortalWordsIntl>
) : (
children
)}
</DraftCopy>
</PreviewBandImageContext.Provider>
</BrandStatusColours>
</PreviewLogoContext.Provider>
</PreviewAttributesContext.Provider>
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/public/preview-draft-context.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useIntl } from 'react-intl'
import type { PortalDesign } from '@/lib/shared/design/schema'
import type { DesignLogo } from '@/lib/shared/design/view'
import type { BandImage, DesignLogo } from '@/lib/shared/design/view'
import type { PortalNavConfig, PortalWelcomeCard } from '@/lib/shared/types/settings'

/**
Expand Down Expand Up @@ -138,6 +138,13 @@ export function useFeedCopy(): { prompt?: string; submit?: string; emptyTitle?:
)
}

export const PreviewBandImageContext = createContext<BandImage | null | undefined>(undefined)

/** A draft design's band image: undefined outside a design preview, null when it has none. */
export function usePreviewBandImage(): BandImage | null | undefined {
return useContext(PreviewBandImageContext)
}

export const PreviewLogoContext = createContext<DesignLogo | undefined>(undefined)

/** A draft design's logo, or undefined (always, outside a design preview). */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('@tanstack/react-start', () => ({
createServerFn: () => {
let handler: ((args: { data: unknown }) => Promise<unknown>) | null = null
const fn = (args: { data: unknown }) => handler!(args)
fn.validator = () => fn
fn.handler = (h: typeof handler) => {
handler = h
return fn
}
return fn
},
}))

vi.mock('../../storage/s3', () => ({
isS3Usable: vi.fn(() => true),
generatePresignedUploadUrl: vi.fn(async (key: string, contentType: string) => ({
uploadUrl: `https://s3.example.com/${key}?presigned`,
key,
contentType,
})),
generateStorageKey: vi.fn((prefix: string, filename: string) => `${prefix}/${filename}`),
isAllowedImageType: vi.fn((type: string) =>
['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(type)
),
MAX_FILE_SIZE: 10 * 1024 * 1024,
}))

const requireAuth = vi.hoisted(() => vi.fn())
vi.mock('../auth-helpers', () => ({ requireAuth }))
vi.mock('../widget-auth', () => ({ getWidgetSession: vi.fn() }))

import { PERMISSIONS } from '@/lib/shared/permissions'
import { getBandImageUploadUrlFn, getLogoUploadUrlFn, getWordmarkUploadUrlFn } from '../uploads'

const file = (filename: string, contentType: string) => ({
data: { filename, contentType, fileSize: 2000 },
})

beforeEach(() => vi.clearAllMocks())

describe('a design’s wordmark upload', () => {
it('takes an SVG, as most logos are, beside the usual images', async () => {
const svg = await getWordmarkUploadUrlFn(file('wordmark.svg', 'image/svg+xml'))
expect(svg).toMatchObject({ key: 'header-logos/wordmark.svg', contentType: 'image/svg+xml' })
await expect(getWordmarkUploadUrlFn(file('w.png', 'image/png'))).resolves.toMatchObject({
key: 'header-logos/w.png',
})
expect(requireAuth).toHaveBeenCalledWith({ permission: PERMISSIONS.SETTINGS_MANAGE })
})

it('still refuses what is not an image', async () => {
await expect(getWordmarkUploadUrlFn(file('page.html', 'text/html'))).rejects.toThrow(
'Invalid image type'
)
})
})

describe('other image uploads', () => {
it('keep refusing SVG: the workspace logo also goes in emails', async () => {
await expect(getLogoUploadUrlFn(file('logo.svg', 'image/svg+xml'))).rejects.toThrow(
'Invalid image type'
)
})

it('take a band image as a photo, never an SVG', async () => {
await expect(getBandImageUploadUrlFn(file('room.jpg', 'image/jpeg'))).resolves.toMatchObject({
key: 'portal-images/room.jpg',
})
await expect(getBandImageUploadUrlFn(file('x.svg', 'image/svg+xml'))).rejects.toThrow(
'Invalid image type'
)
expect(requireAuth).toHaveBeenCalledWith({ permission: PERMISSIONS.SETTINGS_MANAGE })
})
})
Loading
Loading