diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx index a3fef2d6b7..5a32f366fd 100644 --- a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx +++ b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx @@ -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' @@ -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 }) => {children}, })) vi.mock('@/components/admin/settings/branding/portal-preview', () => ({ PortalPreview: ({ design }: { design: { css: string } }) => ( @@ -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' })) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/change-list.test.ts b/apps/web/src/components/admin/settings/appearance/__tests__/change-list.test.ts new file mode 100644 index 0000000000..1f9b923fdd --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/change-list.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/appearance-view.tsx b/apps/web/src/components/admin/settings/appearance/appearance-view.tsx index ec36cbda32..b0e020ff5a 100644 --- a/apps/web/src/components/admin/settings/appearance/appearance-view.tsx +++ b/apps/web/src/components/admin/settings/appearance/appearance-view.tsx @@ -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' @@ -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'>( @@ -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 @@ -50,7 +69,7 @@ export function AppearanceView({ return (
- + setTab(String(next))}>
Brand @@ -61,7 +80,7 @@ export function AppearanceView({
- + @@ -115,6 +134,7 @@ export function AppearanceView({ draftDirty design={draft.preview} scaleToDesktop + sort={draft.design.pages.feedback.defaultSort} viewport={viewport} workspaceName={workspaceName} faviconUrl={faviconUrl} diff --git a/apps/web/src/components/admin/settings/appearance/brand-panel.tsx b/apps/web/src/components/admin/settings/appearance/brand-panel.tsx index 415a97a93e..d44746ccb8 100644 --- a/apps/web/src/components/admin/settings/appearance/brand-panel.tsx +++ b/apps/web/src/components/admin/settings/appearance/brand-panel.tsx @@ -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' @@ -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 @@ -159,6 +169,21 @@ export function BrandPanel({ design, update }: { design: PortalDesign; update: U update((draft) => Object.assign(draft, applyPreset(draft, preset)))} /> +
+ Logo + {logoUrl ? ( + + ) : ( + None + )} + +
= { '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. */ @@ -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 } diff --git a/apps/web/src/components/admin/settings/appearance/drawings.tsx b/apps/web/src/components/admin/settings/appearance/drawings.tsx index cc8900c5b6..42aadb278d 100644 --- a/apps/web/src/components/admin/settings/appearance/drawings.tsx +++ b/apps/web/src/components/admin/settings/appearance/drawings.tsx @@ -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 ( + + {[0, 1, 2].map((row) => ( + + + + + ))} + + ) + return ( + + {heights.map((height, index) => ( + + ))} + + ) +} + export function FiltersDrawing({ filters }: { filters: Layout['filters'] }) { return ( diff --git a/apps/web/src/components/admin/settings/appearance/sections-panel.tsx b/apps/web/src/components/admin/settings/appearance/sections-panel.tsx index b8993f43bb..4406369f8a 100644 --- a/apps/web/src/components/admin/settings/appearance/sections-panel.tsx +++ b/apps/web/src/components/admin/settings/appearance/sections-panel.tsx @@ -13,6 +13,7 @@ import { DensityDrawing, FiltersDrawing, NavDrawing, + SortDrawing, WidthDrawing, } from './drawings' @@ -31,7 +32,7 @@ export function SectionsPanel({ design, update }: { design: PortalDesign; update const { layout } = design return (
-
+

Header

-
+

Top band

-
+
-
+

Feed

, }))} /> + update((draft) => void (draft.pages.feedback.defaultSort = sort))} + options={[ + { + value: 'trending', + label: 'Trending', + drawing: , + }, + { + value: 'top', + label: 'Top', + drawing: , + }, + { + value: 'new', + label: 'New', + drawing: , + }, + ]} + />
-
+

Filters

-
+

Whole site

(null) const [boxWidth, setBoxWidth] = useState(0) @@ -171,7 +177,7 @@ export function PortalPreview({