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 @@ -4,7 +4,7 @@
* draft bar at once, and Review and publish lists it before it goes live.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { defaultDesign } from '@/lib/shared/design/schema'
Expand All @@ -14,6 +14,7 @@ const server = vi.hoisted(() => ({ published: [] as unknown[] }))
vi.mock('@tanstack/react-router', () => ({
useRouter: () => ({ invalidate: async () => {} }),
ClientOnly: ({ children }: { children: ReactNode }) => <>{children}</>,
Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
}))
vi.mock('@/components/admin/settings/branding/portal-preview', () => ({
PortalPreview: ({ design }: { design: { css: string } }) => (
Expand Down Expand Up @@ -70,6 +71,20 @@ describe('AppearanceView', () => {
expect(screen.getByRole('status').textContent).toContain('Draft · 1 change, not live')
})

it('opens Sections when a section is picked in the preview', async () => {
mount()
expect(screen.queryByText('Menu style')).toBeNull()
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'quackback:preview-section', section: 'band' },
origin: window.location.origin,
})
)
})
expect(await screen.findByText('Menu style')).toBeTruthy()
})

it('applies a preset’s whole look to the draft', async () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Start from a preset' }))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Review and publish names each change the way the Appearance controls do.
*/
import { describe, expect, it } from 'vitest'
import { changeLabel } from '../change-list'

describe('changeLabel', () => {
it.each([
['brand.light.accent', 'Accent'],
['brand.dark.ink', 'Dark text'],
['brand.fonts.heading.id', 'Heading font'],
['layout.nav', 'Menu style'],
['pages.feedback.defaultSort', 'Default sort'],
['pages.feedback.headline.title.en', 'Headline'],
['pages.feedback.headline', 'Headline'],
['pages.feedback.headline.subtitle.de', 'Subtitle'],
['pages.feedback.welcome.show', 'Welcome message'],
['pages.feedback.welcome.body.content', 'Welcome message text'],
])('%s is %s', (path, label) => {
expect(changeLabel(path)).toBe(label)
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc'
import type { TiptapContent } from '@/lib/shared/db-types'
import { ClientOnly } from '@tanstack/react-router'
Expand All @@ -22,9 +22,11 @@ import { useDesignDraft } from './use-design-draft'
export function AppearanceView({
workspaceName,
faviconUrl,
logoUrl = null,
}: {
workspaceName: string
faviconUrl: string | null
logoUrl?: string | null
}) {
const draft = useDesignDraft()
const [theme, setTheme] = useState<'light' | 'dark'>(
Expand All @@ -33,6 +35,23 @@ export function AppearanceView({
const [viewport, setViewport] = useState<'desktop' | 'mobile'>('desktop')
const [reviewing, setReviewing] = useState(false)
const [history, setHistory] = useState(false)
const [tab, setTab] = useState('brand')
// A section clicked in the preview opens its controls.
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return
const message = event.data as { type?: string; section?: unknown } | null
if (message?.type !== 'quackback:preview-section' || typeof message.section !== 'string')
return
setTab('sections')
const id = `design-section-${message.section}`
requestAnimationFrame(() =>
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
)
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
// The welcome message reaches the preview on the structural channel, which
// the feedback page already reads; an empty body hides it.
const { show, body } = draft.design.pages.feedback.welcome
Expand All @@ -50,7 +69,7 @@ export function AppearanceView({
return (
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
<div className="min-w-0 space-y-4">
<Tabs defaultValue="brand">
<Tabs value={tab} onValueChange={(next) => setTab(String(next))}>
<div className="flex items-center gap-2">
<TabsList>
<TabsTrigger value="brand">Brand</TabsTrigger>
Expand All @@ -61,7 +80,7 @@ export function AppearanceView({
</Button>
</div>
<TabsContent value="brand" className="pt-4">
<BrandPanel design={draft.design} update={draft.update} />
<BrandPanel design={draft.design} update={draft.update} logoUrl={logoUrl} />
</TabsContent>
<TabsContent value="sections" className="pt-4">
<SectionsPanel design={draft.design} update={draft.update} />
Expand Down Expand Up @@ -115,6 +134,7 @@ export function AppearanceView({
draftDirty
design={draft.preview}
scaleToDesktop
sort={draft.design.pages.feedback.defaultSort}
viewport={viewport}
workspaceName={workspaceName}
faviconUrl={faviconUrl}
Expand Down
29 changes: 27 additions & 2 deletions apps/web/src/components/admin/settings/appearance/brand-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Link } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { normaliseColor } from '@/lib/shared/design/color'
import { BUNDLED_FONTS, type BundledFontId } from '@/lib/shared/design/fonts'
Expand Down Expand Up @@ -145,8 +146,17 @@ function FontSelect({
)
}

/** The Brand tab: mode, colours, fonts and corners. */
export function BrandPanel({ design, update }: { design: PortalDesign; update: Update }) {
/** The Brand tab: the logo, mode, colours, fonts and corners. */
export function BrandPanel({
design,
update,
logoUrl = null,
}: {
design: PortalDesign
update: Update
/** The workspace logo, which Settings › General owns for the portal, the admin and email. */
logoUrl?: string | null
}) {
const [moreColours, setMoreColours] = useState(false)
const { light, dark, fonts } = design.brand
const headingFont = fonts.heading.kind === 'bundled' ? fonts.heading.id : fonts.heading.fallback
Expand All @@ -159,6 +169,21 @@ export function BrandPanel({ design, update }: { design: PortalDesign; update: U
<PresetGallery
onApply={(preset) => update((draft) => Object.assign(draft, applyPreset(draft, preset)))}
/>
<div className="flex items-center gap-3">
<span className="w-32 shrink-0 text-[13px] font-medium">Logo</span>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="size-8 rounded-md border border-border object-contain"
/>
) : (
<span className="text-[13px] text-muted-foreground">None</span>
)}
<Button variant="ghost" size="sm" className="ms-auto" asChild>
<Link to="/admin/settings/general">Change in General</Link>
</Button>
</div>
<DesignTiles
name="design-mode"
label="Mode"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const LABELS: Record<string, string> = {
'layout.density': 'Density',
'layout.width': 'Page width',
'layout.filters': 'Filters side',
'pages.feedback.defaultSort': 'Default sort',
}

/** A change's name as the Appearance controls call it. */
Expand All @@ -33,6 +34,10 @@ export function changeLabel(path: string): string {
const name = COLOURS[colour[2]] ?? colour[2]
return colour[1] === 'dark' ? `Dark ${name.toLowerCase()}` : name
}
if (path.startsWith('pages.feedback.welcome.body')) return 'Welcome message text'
if (path === 'pages.feedback.welcome.show') return 'Welcome message'
const headline = path.match(/^pages\.feedback\.headline(?:\.(title|subtitle))?/)
if (headline) return headline[1] === 'subtitle' ? 'Subtitle' : 'Headline'
const font = path.match(/^(brand\.fonts\.(?:heading|body))\./)
return LABELS[font ? font[1] : path] ?? path
}
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/admin/settings/appearance/drawings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,31 @@ export function BandDrawing({ band }: { band: Layout['band'] }) {
)
}

/** Bars for a feed's order: rising for trending, falling for top, a dot on the newest. */
export function SortDrawing({ sort }: { sort: 'trending' | 'top' | 'new' }) {
const heights = sort === 'top' ? ['h-5', 'h-3.5', 'h-2'] : ['h-2', 'h-3.5', 'h-5']
if (sort === 'new')
return (
<span className="flex w-12 flex-col gap-1">
{[0, 1, 2].map((row) => (
<span key={row} className="flex items-center gap-1">
<span
className={cn('size-1.5 rounded-full', row === 0 ? 'bg-primary' : 'bg-transparent')}
/>
<span className="h-1.5 flex-1 rounded-sm bg-foreground/30" />
</span>
))}
</span>
)
return (
<span className="flex items-end gap-1">
{heights.map((height, index) => (
<span key={index} className={cn('w-2 rounded-sm bg-foreground/35', height)} />
))}
</span>
)
}

export function FiltersDrawing({ filters }: { filters: Layout['filters'] }) {
return (
<span className={cn('flex w-14 gap-1', filters === 'start' && 'flex-row-reverse')}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DensityDrawing,
FiltersDrawing,
NavDrawing,
SortDrawing,
WidthDrawing,
} from './drawings'

Expand All @@ -31,7 +32,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
const { layout } = design
return (
<div className="space-y-8">
<section className="space-y-4">
<section id="design-section-header" className="scroll-mt-4 space-y-4">
<h3 className="text-sm font-semibold">Header</h3>
<DesignTiles
name="design-header"
Expand All @@ -57,7 +58,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
/>
</section>

<section className="space-y-4">
<section id="design-section-band" className="scroll-mt-4 space-y-4">
<h3 className="text-sm font-semibold">Top band</h3>
<DesignTiles
name="design-band"
Expand Down Expand Up @@ -96,7 +97,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
))}
</section>

<section className="space-y-3">
<section id="design-section-welcome" className="scroll-mt-4 space-y-3">
<SettingRow
label="Welcome message"
htmlFor="design-welcome"
Expand All @@ -118,7 +119,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
)}
</section>

<section className="space-y-4">
<section id="design-section-feed" className="scroll-mt-4 space-y-4">
<h3 className="text-sm font-semibold">Feed</h3>
<DesignTiles
name="design-cards"
Expand All @@ -131,6 +132,29 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
drawing: <CardsDrawing cards={cards} />,
}))}
/>
<DesignTiles
name="design-sort"
label="Default sort"
value={design.pages.feedback.defaultSort}
onChange={(sort) => update((draft) => void (draft.pages.feedback.defaultSort = sort))}
options={[
{
value: 'trending',
label: 'Trending',
drawing: <SortDrawing sort="trending" />,
},
{
value: 'top',
label: 'Top',
drawing: <SortDrawing sort="top" />,
},
{
value: 'new',
label: 'New',
drawing: <SortDrawing sort="new" />,
},
]}
/>
<DesignTiles
name="design-density"
label="Density"
Expand All @@ -144,7 +168,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
/>
</section>

<section className="space-y-4">
<section id="design-section-filters" className="scroll-mt-4 space-y-4">
<h3 className="text-sm font-semibold">Filters</h3>
<DesignTiles
name="design-filters"
Expand All @@ -159,7 +183,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update
/>
</section>

<section className="space-y-4">
<section id="design-section-site" className="scroll-mt-4 space-y-4">
<h3 className="text-sm font-semibold">Whole site</h3>
<DesignTiles
name="design-width"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface PortalPreviewProps {
* desktop layouts (an inline header, the filter panel) show as visitors see them.
*/
scaleToDesktop?: boolean
/** The feed's sort in the frame; a design's default sort, which changes by reloading it. */
sort?: 'trending' | 'top' | 'new'
/** A draft portal design, resolved: applied in the frame in place of the saved one. */
design?: {
css: string
Expand All @@ -49,8 +51,11 @@ const DESKTOP_WIDTH = 1280
* preview flag as a boolean, the default sort included) so the frame loads
* without a redirect first.
*/
export function portalPreviewSrc(theme: 'light' | 'dark') {
return `/?theme=${theme}&preview=true&sort=trending`
export function portalPreviewSrc(
theme: 'light' | 'dark',
sort: 'trending' | 'top' | 'new' = 'trending'
) {
return `/?theme=${theme}&preview=true&sort=${sort}`
}

/**
Expand All @@ -72,6 +77,7 @@ export function PortalPreview({
faviconUrl,
design = null,
scaleToDesktop = false,
sort = 'trending',
}: PortalPreviewProps) {
const frameBox = useRef<HTMLDivElement>(null)
const [boxWidth, setBoxWidth] = useState(0)
Expand Down Expand Up @@ -171,7 +177,7 @@ export function PortalPreview({
<iframe
key={refreshKey}
ref={iframeRef}
src={portalPreviewSrc(theme)}
src={portalPreviewSrc(theme, sort)}
title="Portal preview"
className="w-full border-0 bg-background"
style={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,9 @@ describe('PortalHeader — design layouts', () => {
it('keeps two rows on the legacy theme and for a stacked design', () => {
for (const portalDesign of [undefined, design('stacked')]) {
const { container, unmount } = mountWith(portalDesign)
expect(container.querySelector('header')?.hasAttribute('data-qb-section')).toBe(false)
const header = container.querySelector('header')!
expect(header.querySelector('.md\\:block nav')).toBeNull()
expect(header.querySelector('.justify-center nav')).toBeNull()
unmount()
}
})
Expand Down
Loading
Loading