From d13ad57ef26403d20b67d38419cd2d371876ab9f Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 9 Oct 2026 17:53:05 +0200 Subject: [PATCH 1/3] feat(ui): blog layout, prose styles, and ThemeSwitch refactor - Blog post and listing pages: two-column grid layout with sticky sidebar, ArticleSidebar TOC + links sections, font-mono headings - Legal pages: same two-column grid with extracted headings for TOC - Prose: replace @tailwindcss/typography with custom @layer components block using shadcn tokens; add Lexical-specific styles (checklist, nested lists, ordered-list depth cycling, sub/sup) - ThemeToggle renamed to ThemeSwitch; FooterThemeSwitcher replaced by FooterThemeSwitch; Storybook story updated - TwoColumnContentBlock: add verticalAlignment select field (Top/Center/End) wired into Columns renderer via items-start/center/end - next.config.ts: turbopackServerFastRefresh disabled - Remove @tailwindcss/typography package --- ...on.stories.tsx => ThemeSwitch.stories.tsx} | 14 +- app/(frontend)/[slug]/page.tsx | 24 +- app/(frontend)/blog/_shared/BlogListPage.tsx | 68 ++--- app/(frontend)/blog/post/[slug]/page.tsx | 37 ++- app/(payload)/admin/importMap.js | 2 + bun.lock | 7 +- next.config.ts | 2 +- package.json | 1 - .../Renderer/JobEntry.tsx | 67 ----- .../Renderer/Renderer.client.tsx | 18 +- .../Renderer/Columns.tsx | 16 +- .../components/ColumnsBlock.tsx | 27 +- src/blocks/TwoColumnContentBlock/index.ts | 11 + src/components/Banner/Banner.tsx | 44 +-- src/components/ButtonGroup/ButtonGroup.tsx | 12 +- src/components/Footer/Footer.tsx | 5 +- src/components/Footer/FooterThemeSwitch.tsx | 23 ++ src/components/Footer/FooterThemeSwitcher.tsx | 93 ------ src/components/Separator/Separator.tsx | 4 +- .../ThemeSwitch/ThemeSwitch.config.ts | 20 ++ src/components/ThemeSwitch/ThemeSwitch.tsx | 68 +++++ .../ThemeSwitch/ThemeSwitchButton.tsx | 34 +++ src/components/ThemeSwitch/index.ts | 1 + .../ThemeToggle/ThemeToggleIIconButton.tsx | 30 -- src/components/ThemeToggle/index.ts | 1 - src/styles/frontend.css | 269 ++++++++++++++---- src/styles/payload.css | 2 - src/types/payload.ts | 4 + .../ScheduledJobsWidget.List.tsx | 26 +- .../StatusBannersWidget/StatusBannerItem.tsx | 12 +- 30 files changed, 556 insertions(+), 386 deletions(-) rename .storybook/components/{ThemeToggleIconButton.stories.tsx => ThemeSwitch.stories.tsx} (73%) delete mode 100644 src/blocks/ResumeExperienceBlock/Renderer/JobEntry.tsx create mode 100644 src/components/Footer/FooterThemeSwitch.tsx delete mode 100644 src/components/Footer/FooterThemeSwitcher.tsx create mode 100644 src/components/ThemeSwitch/ThemeSwitch.config.ts create mode 100644 src/components/ThemeSwitch/ThemeSwitch.tsx create mode 100644 src/components/ThemeSwitch/ThemeSwitchButton.tsx create mode 100644 src/components/ThemeSwitch/index.ts delete mode 100644 src/components/ThemeToggle/ThemeToggleIIconButton.tsx delete mode 100644 src/components/ThemeToggle/index.ts diff --git a/.storybook/components/ThemeToggleIconButton.stories.tsx b/.storybook/components/ThemeSwitch.stories.tsx similarity index 73% rename from .storybook/components/ThemeToggleIconButton.stories.tsx rename to .storybook/components/ThemeSwitch.stories.tsx index 2f13bc21..e2ac9024 100644 --- a/.storybook/components/ThemeToggleIconButton.stories.tsx +++ b/.storybook/components/ThemeSwitch.stories.tsx @@ -2,11 +2,11 @@ import { useState } from 'react' import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { ThemeToggleIconButton } from '@/components/ThemeToggle' +import { ThemeSwitch } from '@/components/ThemeSwitch' const meta = { - title: 'ThemeToggleIconButton', - component: ThemeToggleIconButton, + title: 'ThemeSwitch', + component: ThemeSwitch, args: { theme: 'light', }, @@ -15,13 +15,13 @@ const meta = { control: { type: 'select', }, - options: ['light', 'dark'], + options: ['light', 'system', 'dark'], }, setTheme: { control: false, }, }, -} satisfies Meta +} satisfies Meta export default meta @@ -57,7 +57,7 @@ export const Interactive: Story = { }, }, render: () => { - const [theme, setTheme] = useState('light') - return + const [theme, setTheme] = useState('system') + return }, } diff --git a/app/(frontend)/[slug]/page.tsx b/app/(frontend)/[slug]/page.tsx index e9fd07bc..bf3d4587 100644 --- a/app/(frontend)/[slug]/page.tsx +++ b/app/(frontend)/[slug]/page.tsx @@ -8,10 +8,12 @@ import config from '@payload-config' import { getPayload } from 'payload' import { RenderBlocks } from '@/blocks/RenderBlocks' +import { ArticleSidebar } from '@/components/ArticleSidebar' import { Headline } from '@/components/Headline' import { HeroMedia } from '@/components/HeroMedia' import { LivePreviewListener } from '@/components/LivePreviewListener' import { PageContainer } from '@/components/PageContainer' +import { extractHeadings } from '@/lib/extractHeadings' import { extractSections } from '@/lib/extractSections' import { generateMeta } from '@/lib/generateMeta' import { placeholderParams } from '@/lib/placeholderParams' @@ -59,6 +61,15 @@ export default async function Page({ params }: PageProps) { if (!page) notFound() const { title, layout, hero, content } = page + + const legalHeadings = + layout === 'legal' + ? (content ?? []).flatMap((block) => { + const lexical = (block as { content?: unknown }).content + return extractHeadings(lexical) + }) + : [] + return ( )} - + {layout === 'legal' ? ( +
+
+
+ +
+ +
+
+ ) : ( + + )} {draft && }
diff --git a/app/(frontend)/blog/_shared/BlogListPage.tsx b/app/(frontend)/blog/_shared/BlogListPage.tsx index 71f16cf3..1480c0f8 100644 --- a/app/(frontend)/blog/_shared/BlogListPage.tsx +++ b/app/(frontend)/blog/_shared/BlogListPage.tsx @@ -10,8 +10,6 @@ import { cn } from 'tailwind-variants' import { Button } from '@/components/Button' import { DuoTone } from '@/components/DuoTone' -import { Headline } from '@/components/Headline' -import { HeroMedia } from '@/components/HeroMedia' import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets' import { toSlideItems } from '@/components/HeroMedia/toSlideItems' import { ImageMedia } from '@/components/ImageMedia' @@ -260,7 +258,11 @@ const PostsGrid = async ({ return ( - {totalDocs > 1 && } + {totalDocs > 1 ? ( + + ) : ( +
+ )} {posts.map((post) => ( ))} @@ -296,61 +298,27 @@ export interface BlogListPageProps { */ export const BlogListPage = async ({ topic, searchParams }: BlogListPageProps) => { const basePath = topic ? `${BLOG_PATH}/${topic.slug}` : BLOG_PATH - const hasTopicHero = toSlideItems(topic?.hero?.slides, topic?.title ?? '').length > 0 - return ( - {hasTopicHero && ( - -
-
- - {topic?.title} - -
-
-
- )} -
+
-
+
- -

+

{topic ? topic.title : 'All Posts'}

-

- Blog posts are a great way to share your knowledge and expertise with the world. -

+ {topic?.content && !isEmptyValue(topic.content) && ( + + )}
-
+
diff --git a/app/(frontend)/blog/post/[slug]/page.tsx b/app/(frontend)/blog/post/[slug]/page.tsx index 0c862d50..48d74b68 100644 --- a/app/(frontend)/blog/post/[slug]/page.tsx +++ b/app/(frontend)/blog/post/[slug]/page.tsx @@ -13,7 +13,9 @@ import { format } from 'date-fns' import { ArticleSidebar } from '@/components/ArticleSidebar' import { HeroMedia } from '@/components/HeroMedia' +import { CMSLink } from '@/components/Link' import { RichText } from '@/components/RichText' +import type { LinkFieldDataLean } from '@/fields/Link/lib/resolveLinkTarget' import { extractHeadings } from '@/lib/extractHeadings' import { generateMeta } from '@/lib/generateMeta' import { reduceDataToBilingualLanguage } from '@/lib/i18n' @@ -64,7 +66,7 @@ export default async function Page({ params: paramsPromise }: PageProps) { }) if (!post) return notFound() - const { title, content, hero, createdAt, updatedAt, topics } = post + const { title, content, hero, createdAt, updatedAt, topics, links } = post const baseUrl = process.env.SERVER_URL || 'https://danielheene.de' const postUrl = `${baseUrl}/posts/${slug}` @@ -161,7 +163,7 @@ export default async function Page({ params: paramsPromise }: PageProps) {
-

+

{title}

{post.meta?.description && ( @@ -189,9 +191,9 @@ export default async function Page({ params: paramsPromise }: PageProps) {
-
+
{/* scroll-mt keeps TOC anchor targets clear of the fixed header */} -
+
{content ? ( // RichText is a Client Component that calls randomUUID(), which // Cache Components requires to sit behind a Suspense boundary @@ -207,7 +209,32 @@ export default async function Page({ params: paramsPromise }: PageProps) { )}
- + 0 + ? [ + { + title: 'Links', + children: ( +
    + {links.map(({ id, link }) => ( +
  • + +
  • + ))} +
+ ), + }, + ] + : [] + } + />
diff --git a/app/(payload)/admin/importMap.js b/app/(payload)/admin/importMap.js index a08a4ce5..d1b9283b 100644 --- a/app/(payload)/admin/importMap.js +++ b/app/(payload)/admin/importMap.js @@ -78,6 +78,7 @@ import { Nav as Nav_a316a6ec12a12cd0fe58ccec01a444ca } from '@/components/AdminP import { Icon as Icon_a316a6ec12a12cd0fe58ccec01a444ca } from '@/components/AdminPanel' import { Logo as Logo_a316a6ec12a12cd0fe58ccec01a444ca } from '@/components/AdminPanel' import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' +import { AdminErrorBoundary as AdminErrorBoundary_e5a9e14bdbe97e70ba60697217fe7688 } from '@payloadcms/plugin-sentry/client' import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { StatusBannersWidget as StatusBannersWidget_520e3069c7e11d3b5b05991c8b487452 } from '@/widgets/StatusBannersWidget' import { ScheduledJobsWidget as ScheduledJobsWidget_5a82a9e565d2b9686dc0a96e9db621b7 } from '@/widgets/ScheduledJobsWidget' @@ -166,6 +167,7 @@ export const importMap = { "@/components/AdminPanel#Icon": Icon_a316a6ec12a12cd0fe58ccec01a444ca, "@/components/AdminPanel#Logo": Logo_a316a6ec12a12cd0fe58ccec01a444ca, "@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36, + "@payloadcms/plugin-sentry/client#AdminErrorBoundary": AdminErrorBoundary_e5a9e14bdbe97e70ba60697217fe7688, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@/widgets/StatusBannersWidget#StatusBannersWidget": StatusBannersWidget_520e3069c7e11d3b5b05991c8b487452, "@/widgets/ScheduledJobsWidget#ScheduledJobsWidget": ScheduledJobsWidget_5a82a9e565d2b9686dc0a96e9db621b7, diff --git a/bun.lock b/bun.lock index c4e173f4..514000bb 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,6 @@ "@react-pdf/types": "^2.14.0", "@sentry/nextjs": "^11.0.0", "@sindresorhus/slugify": "^3.0.1", - "@tailwindcss/typography": "^0.5.20", "ai": "^7.0.107", "date-fns": "^4.4.0", "dedent": "^1.7.2", @@ -1320,8 +1319,6 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], - "@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="], - "@takumi-rs/core": ["@takumi-rs/core@2.14.0", "", { "dependencies": { "@takumi-rs/helpers": "2.14.0" }, "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "2.14.0", "@takumi-rs/core-darwin-x64": "2.14.0", "@takumi-rs/core-linux-arm64-gnu": "2.14.0", "@takumi-rs/core-linux-arm64-musl": "2.14.0", "@takumi-rs/core-linux-x64-gnu": "2.14.0", "@takumi-rs/core-linux-x64-musl": "2.14.0", "@takumi-rs/core-win32-arm64-msvc": "2.14.0", "@takumi-rs/core-win32-x64-msvc": "2.14.0" }, "peerDependencies": { "csstype": "*" }, "optionalPeers": ["csstype"] }, "sha512-PGgLdS+A1xaxPhJtYy/d+k5GYw9MOcwcLMs2zT4RGNkw28FxN+WWHvR1+nnn34rdyD6sG6K2W+50oQZtQV1h/g=="], "@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@2.14.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-To8/1EFFVM40V3FlkQIkOl2Ge/xg6bP8KWlxoeBUEusJvjZ3d3PfmKoVmG9erM3oh7ztzw1u3nUXQqLFTNeJBQ=="], @@ -2766,7 +2763,7 @@ "postcss-reduce-transforms": ["postcss-reduce-transforms@9.0.4", "", { "dependencies": { "cssnano-utils": "^8.0.0" }, "peerDependencies": { "postcss": "^8.5.28" } }, "sha512-hRzepc/cwIs1cCdIghQatRDFlxc7qQieXzkyCfyVl8KPTPhOZyOtdPp9Qt90fMbqWry6YdMQu/YfScxh47sTYg=="], - "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], "postcss-svgo": ["postcss-svgo@9.0.4", "", { "dependencies": { "@csstools/css-tokenizer": "^4.0.2", "cssnano-utils": "^8.0.0", "svgo": "^4.1.0" }, "peerDependencies": { "postcss": "^8.5.28" } }, "sha512-0QGF1sORc244E+DNJxEzQDK/zK24VO0CMzAMOwOW+cQ90Y65mVDR2yrSxp4/f+XcdxmAf5LPm22zlnhVUTbtAA=="], @@ -3930,8 +3927,6 @@ "postcss-merge-rules/cssnano-utils": ["cssnano-utils@7.0.3", "", { "peerDependencies": { "postcss": "^8.5.28" } }, "sha512-KQKwel4/ROikvA9XkOkG7JbmgsipMSl8E7LwvDCjE32zKVz2xjNxxEJxJpbNzCLeeV1WbbD1fRmSUgtuOQeRgg=="], - "postcss-merge-rules/postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-datepicker/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], diff --git a/next.config.ts b/next.config.ts index b4ba7ca9..8b0f3448 100644 --- a/next.config.ts +++ b/next.config.ts @@ -127,7 +127,7 @@ export default async (phase, { defaultConfig }) => { experimental: { appNewScrollHandler: true, - turbopackServerFastRefresh: true, + turbopackServerFastRefresh: false, serverActions: { bodySizeLimit: '10mb', }, diff --git a/package.json b/package.json index c5a630db..368de350 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,6 @@ "@react-pdf/types": "^2.14.0", "@sentry/nextjs": "^11.0.0", "@sindresorhus/slugify": "^3.0.1", - "@tailwindcss/typography": "^0.5.20", "ai": "^7.0.107", "date-fns": "^4.4.0", "dedent": "^1.7.2", diff --git a/src/blocks/ResumeExperienceBlock/Renderer/JobEntry.tsx b/src/blocks/ResumeExperienceBlock/Renderer/JobEntry.tsx deleted file mode 100644 index 6c1e05d8..00000000 --- a/src/blocks/ResumeExperienceBlock/Renderer/JobEntry.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { convertLexicalToPlaintext } from '@payloadcms/richtext-lexical/plaintext' - -import { cn } from 'tailwind-variants' - -import type { ReducedToBilingualLanguage } from '@/lib/i18n' -import { generateExperienceInterval } from '@/lib/i18n' -import { isEmptyValue } from '@/lib/lexical/isEmptyValue' -import type { ResumeJobData } from '@/types/payload' - -export const JobEntry = ({ - title, - employer, - startDate, - endDate, - tasks, - // technologies, -}: ReducedToBilingualLanguage) => { - const timeString = generateExperienceInterval({ - startDate, - endDate, - }) - - return ( -
-
-

- {employer} - {title} -

- -
- {Array.isArray(tasks) && tasks.length > 0 && ( -
    - {tasks - // `task` is rich text, not a string - .filter(({ task }) => !isEmptyValue(task)) - .map(({ task, id }) => ( -
  • - {convertLexicalToPlaintext({ - data: task, - })} -
  • - ))} -
- )} - {/*{technologies.length > 0 && (*/} - {/*
*/} - {/* {technologies.map(({ id, label }) => (*/} - {/* */} - {/* {label}*/} - {/* */} - {/* ))}*/} - {/*
*/} - {/*)}*/} -
- ) -} diff --git a/src/blocks/ResumeExperienceBlock/Renderer/Renderer.client.tsx b/src/blocks/ResumeExperienceBlock/Renderer/Renderer.client.tsx index 8b8cebcc..222dbcf5 100644 --- a/src/blocks/ResumeExperienceBlock/Renderer/Renderer.client.tsx +++ b/src/blocks/ResumeExperienceBlock/Renderer/Renderer.client.tsx @@ -101,16 +101,22 @@ export const ResumeExperienceBlockClientRenderer = ({ {Array.isArray(tasks) && tasks.length > 0 && ( -
    +
    {tasks - // `task` is rich text, not a string .filter(({ task }) => !isEmptyValue(task)) .map(({ task, id }) => ( -
  • - -
  • + p]:py-0 [&>p]:my-0', + 'before:block before:w-2 before:h-2 before:rounded-full before:absolute before:left-0 before:top-[0.65rem] before:bg-primary', + ])} + /> ))} -
+
)} {/**/} {/*{technologies.length > 0 && (*/} diff --git a/src/blocks/TwoColumnContentBlock/Renderer/Columns.tsx b/src/blocks/TwoColumnContentBlock/Renderer/Columns.tsx index 2bf40ac5..ccab26e3 100644 --- a/src/blocks/TwoColumnContentBlock/Renderer/Columns.tsx +++ b/src/blocks/TwoColumnContentBlock/Renderer/Columns.tsx @@ -10,8 +10,15 @@ import type { RichTextBlock } from '@/types/payload' * Payload generates no interface for a block that only Lexical references, so * the columns are typed as the rich-text value they hold. */ +const ALIGN_CLASS: Record = { + start: 'items-start', + center: 'items-center', + end: 'items-end', +} + type ColumnsProps = { className?: string + verticalAlignment?: 'start' | 'center' | 'end' contentLeft?: RichTextBlock['content'] contentRight?: RichTextBlock['content'] highlightedLeft?: HighlightedCodeMap @@ -27,12 +34,19 @@ type ColumnsProps = { */ export const Columns = ({ className, + verticalAlignment = 'start', contentLeft, contentRight, highlightedLeft, highlightedRight, }: ColumnsProps): JSX.Element => ( -
+
diff --git a/src/blocks/TwoColumnContentBlock/components/ColumnsBlock.tsx b/src/blocks/TwoColumnContentBlock/components/ColumnsBlock.tsx index 8948a0cb..be00121e 100644 --- a/src/blocks/TwoColumnContentBlock/components/ColumnsBlock.tsx +++ b/src/blocks/TwoColumnContentBlock/components/ColumnsBlock.tsx @@ -2,7 +2,7 @@ import { useBlockComponentContext } from '@payloadcms/richtext-lexical/client' import { useLexicalEditable } from '@payloadcms/richtext-lexical/lexical/react/useLexicalEditable' -import { RenderFields } from '@payloadcms/ui' +import { RenderFields, useDocumentForm, useField, useForm } from '@payloadcms/ui' import { cn } from 'tailwind-variants' @@ -16,8 +16,11 @@ import { cn } from 'tailwind-variants' * would only repeat the same two editors. */ export const ColumnsBlock = () => { - const { formSchema, RemoveButton } = useBlockComponentContext() + const { formSchema } = useBlockComponentContext() const isEditable = useLexicalEditable() + const { value } = useField({ path: 'verticalAlignment' }) + + console.log(formSchema, value) return (
{ String.raw`[&_.editor-container]:flex [&_.editor-container]:flex-col`, String.raw`[&_.editor-scroller]:flex [&_.editor-scroller]:grow [&_.editor-scroller]:flex-col`, String.raw`[&_.editor]:flex [&_.editor]:grow [&_.editor]:flex-col`, + String.raw`[&_.ContentEditable\_\_root]:grow`, // A lower floor than the full-width editor's 500px. + String.raw`[&_.ContentEditable\_\_root]:flex`, + String.raw`[&_.ContentEditable\_\_root]:flex-col`, String.raw`[&_.ContentEditable\_\_root]:min-h-37.5!`, + value === 'start' && String.raw`[&_.ContentEditable\_\_root]:justify-start`, + value === 'center' && String.raw`[&_.ContentEditable\_\_root]:justify-center`, + value === 'end' && String.raw`[&_.ContentEditable\_\_root]:justify-end`, ])} > - {isEditable && ( -
- -
- )} = { } as const export const bannerStyles = tv({ - base: cn(['block py-4 pl-16 pr-4 relative', 'font-medium font-pp-supply-mono']), + base: [ + 'block py-4 pl-16 pr-4 relative', + 'font-medium font-pp-supply-mono', + '[&>svg]:text-[2rem] [&>svg]:leading-[2rem] [&>svg]:absolute [&>svg]:left-4', + ], variants: { variant: { - [BannerVariant.Neutral]: cn(['[--banner-color:var(--color-neutral-700)]']), - [BannerVariant.Info]: cn(['[--banner-color:var(--color-info-600)]']), - [BannerVariant.Success]: cn(['[--banner-color:var(--color-success-600)]']), - [BannerVariant.Warning]: cn(['[--banner-color:var(--color-warning-700)]']), - [BannerVariant.Error]: cn(['[--banner-color:var(--color-error-700)]']), + [BannerVariant.Neutral]: ['[--banner-color:var(--color-neutral-700)]'], + [BannerVariant.Info]: ['[--banner-color:var(--color-info-600)]'], + [BannerVariant.Success]: ['[--banner-color:var(--color-success-600)]'], + [BannerVariant.Warning]: ['[--banner-color:var(--color-warning-700)]'], + [BannerVariant.Error]: ['[--banner-color:var(--color-error-700)]'], }, inverse: { - false: cn(['bg-(--banner-color)', 'text-(--color-white)']), - true: cn(['bg-(--color-white)', 'text-(--banner-color)']), + false: ['bg-(--banner-color)', 'text-(--color-white)'], + true: ['bg-(--color-white)', 'text-(--banner-color)'], + }, + vAlignIcon: { + true: ['[&>svg]:top-1/2 [&>svg]:translate-y-[-50%]'], + false: ['[&>svg]:top-4 '], + }, + rotateIcon: { + true: ['[&>svg]:animate-spin'], + false: [], }, }, defaultVariants: { inverse: false, + vAlignIcon: false, + rotateIcon: false, }, }) interface BannerProps extends VariantProps { className?: string customIcon?: string - rotateIcon?: boolean children?: React.ReactNode } export const Banner = ({ className, customIcon = '', - rotateIcon = false, + vAlignIcon, + rotateIcon, children, variant = BannerVariant.Neutral, inverse = false, @@ -59,16 +73,12 @@ export const Banner = ({ className={bannerStyles({ variant, inverse, + vAlignIcon, + rotateIcon, class: className, })} > - +
{children}
) diff --git a/src/components/ButtonGroup/ButtonGroup.tsx b/src/components/ButtonGroup/ButtonGroup.tsx index 5b6eca82..3a5a91bd 100644 --- a/src/components/ButtonGroup/ButtonGroup.tsx +++ b/src/components/ButtonGroup/ButtonGroup.tsx @@ -6,26 +6,26 @@ import { cn, tv, VariantProps } from 'tailwind-variants' import { Separator } from '../Separator' const buttonGroupStyles = tv({ - base: cn([ + base: [ 'group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10', 'has-[>[data-slot=button-group]]:gap-2', 'has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg', "[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", - ]), + ], variants: { orientation: { - horizontal: cn([ + horizontal: [ '[&>*:not(:first-child)]:rounded-l-none', '[&>*:not(:first-child)]:border-l-0', '[&>*:not(:last-child)]:rounded-r-none', '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!', - ]), - vertical: cn([ + ], + vertical: [ 'flex-col [&>*:not(:first-child)]:rounded-t-none', '[&>*:not(:first-child)]:border-t-0', '[&>*:not(:last-child)]:rounded-b-none', '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!', - ]), + ], }, }, defaultVariants: { diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index e81c53db..5bb762f8 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -11,7 +11,7 @@ import type { LinkFieldDataLean, NavEntry } from '@/fields/Link/lib/resolveLinkT import { fetchGlobalUserSettingsCached, fetchSiteSettingsCached } from '@/lib/fetchers' import { FooterSocialLinks } from './FooterSocialLinks' -import { FooterThemeSwitcher } from './FooterThemeSwitcher' +import { FooterThemeSwitch } from './FooterThemeSwitch' export const Footer = async () => { const { @@ -119,8 +119,7 @@ export const Footer = async () => {
- {' '} - +
diff --git a/src/components/Footer/FooterThemeSwitch.tsx b/src/components/Footer/FooterThemeSwitch.tsx new file mode 100644 index 00000000..3e0bc281 --- /dev/null +++ b/src/components/Footer/FooterThemeSwitch.tsx @@ -0,0 +1,23 @@ +'use client' + +import { JSX } from 'react' +import { useTheme } from 'next-themes' + +import { ThemeSwitch } from '@/components/ThemeSwitch' + +interface FooterThemeSwitchProps { + className?: string +} + +export const FooterThemeSwitch = ({ className }: FooterThemeSwitchProps): JSX.Element => { + const { theme, setTheme } = useTheme() + + return ( + + ) +} diff --git a/src/components/Footer/FooterThemeSwitcher.tsx b/src/components/Footer/FooterThemeSwitcher.tsx deleted file mode 100644 index 85234aab..00000000 --- a/src/components/Footer/FooterThemeSwitcher.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client' - -import { JSX, useEffect, useMemo, useState } from 'react' -import { useTheme } from 'next-themes' - -import { cn } from 'tailwind-variants' - -import { Icon } from '@/components/Icon' - -const themeConfigs = [ - { - key: 'light', - label: 'toggle light theme', - icon: 'lucide:sun', - }, - { - key: 'system', - label: 'toggle system theme', - icon: 'lucide:monitor', - }, - { - key: 'dark', - label: 'toggle dark theme', - icon: 'lucide:moon', - }, -] as const - -type ThemeKey = (typeof themeConfigs)[number]['key'] - -interface FooterThemeSwitcherProps { - className?: string - options: ThemeKey[] -} - -export const FooterThemeSwitcher = ({ - className, - options = [], -}: FooterThemeSwitcherProps): JSX.Element => { - const [mounted, setMounted] = useState(false) - const { theme: currentTheme, setTheme } = useTheme() - - useEffect(() => { - if (typeof window !== 'undefined') { - setMounted(true) - } - }, []) - - const selectedThemes = useMemo( - () => themeConfigs.filter(({ key }) => options.includes(key)), - [options], - ) - - if (!mounted) return null - return ( -
- {selectedThemes.map(({ key, label, icon }) => ( - setTheme(key)} - label={label} - > - - - ))} -
- ) -} - -const ThemeSwitcherButton = ({ children, isActive, onClick, label }) => ( - -) diff --git a/src/components/Separator/Separator.tsx b/src/components/Separator/Separator.tsx index 07e1a0af..470e2b02 100644 --- a/src/components/Separator/Separator.tsx +++ b/src/components/Separator/Separator.tsx @@ -18,8 +18,8 @@ export const Separator = ({ orientation={orientation} className={cn( 'shrink-0 bg-border', - 'data-horizontal:h-px data-horizontal:w-full', - 'data-vertical:w-px data-vertical:self-stretch', + 'data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full', + 'data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch', className, )} {...props} diff --git a/src/components/ThemeSwitch/ThemeSwitch.config.ts b/src/components/ThemeSwitch/ThemeSwitch.config.ts new file mode 100644 index 00000000..5c4b65bd --- /dev/null +++ b/src/components/ThemeSwitch/ThemeSwitch.config.ts @@ -0,0 +1,20 @@ +export const config = [ + { + key: 'light', + label: 'toggle light theme', + icon: 'lucide:sun', + }, + { + key: 'system', + label: 'toggle system theme', + icon: 'lucide:monitor', + }, + { + key: 'dark', + label: 'toggle dark theme', + icon: 'lucide:moon', + }, +] as const + +export type ThemeConfig = (typeof config)[number] +export type ThemeKey = ThemeConfig['key'] diff --git a/src/components/ThemeSwitch/ThemeSwitch.tsx b/src/components/ThemeSwitch/ThemeSwitch.tsx new file mode 100644 index 00000000..49131976 --- /dev/null +++ b/src/components/ThemeSwitch/ThemeSwitch.tsx @@ -0,0 +1,68 @@ +'use client' + +import { JSX, useEffect, useMemo, useState } from 'react' + +import { cn } from 'tailwind-variants' + +import { Icon } from '@/components/Icon' +import { ThemeSwitchButton } from '@/components/ThemeSwitch/ThemeSwitchButton' + +import { config, ThemeKey, ThemeConfig } from './ThemeSwitch.config' + +interface ThemeSwitchProps { + className?: string + options?: ThemeKey[] + theme: string + setTheme: (theme: string) => void +} + +export const ThemeSwitch = ({ + className, + options = ['light', 'system', 'dark'], + theme, + setTheme, +}: ThemeSwitchProps): JSX.Element => { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + if (typeof window !== 'undefined') { + setMounted(true) + } + }, []) + + const selectedThemes: ThemeConfig[] = useMemo( + () => + config + .filter(({ key }) => options.includes(key)) + .sort( + (a, b) => + config.findIndex(({ key }) => key === a.key) - + config.findIndex(({ key }) => key === b.key), + ), + [options], + ) + + if (!mounted) return null + return ( +
+ {selectedThemes.map(({ key, label, icon }) => ( + setTheme(key)} + label={label} + > + + + ))} +
+ ) +} diff --git a/src/components/ThemeSwitch/ThemeSwitchButton.tsx b/src/components/ThemeSwitch/ThemeSwitchButton.tsx new file mode 100644 index 00000000..d07157a7 --- /dev/null +++ b/src/components/ThemeSwitch/ThemeSwitchButton.tsx @@ -0,0 +1,34 @@ +import React, { JSX } from 'react' + +import { cn } from 'tailwind-variants' + +interface ThemeSwitchButtonProps { + children: React.ReactNode + isActive: boolean + onClick: () => void + label: string +} + +export const ThemeSwitchButton = ({ + children, + isActive, + onClick, + label, +}: ThemeSwitchButtonProps): JSX.Element => ( + +) diff --git a/src/components/ThemeSwitch/index.ts b/src/components/ThemeSwitch/index.ts new file mode 100644 index 00000000..51a8682d --- /dev/null +++ b/src/components/ThemeSwitch/index.ts @@ -0,0 +1 @@ +export { ThemeSwitch } from './ThemeSwitch' diff --git a/src/components/ThemeToggle/ThemeToggleIIconButton.tsx b/src/components/ThemeToggle/ThemeToggleIIconButton.tsx deleted file mode 100644 index 0acf44e8..00000000 --- a/src/components/ThemeToggle/ThemeToggleIIconButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import { Button } from '../Button' -import { Icon } from '../Icon' - -interface ThemeToggleIconButtonProps { - theme?: string - setTheme: (theme?: string) => void -} -export function ThemeToggleIconButton({ theme, setTheme }: ThemeToggleIconButtonProps) { - return ( - - ) -} diff --git a/src/components/ThemeToggle/index.ts b/src/components/ThemeToggle/index.ts deleted file mode 100644 index a5b5be61..00000000 --- a/src/components/ThemeToggle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ThemeToggleIconButton } from './ThemeToggleIIconButton' diff --git a/src/styles/frontend.css b/src/styles/frontend.css index 6828f7ec..b9dc3ad4 100644 --- a/src/styles/frontend.css +++ b/src/styles/frontend.css @@ -14,8 +14,6 @@ @import './theme/_text.css' layer(theme); @import './theme/_text-shadows.css' layer(theme); -@plugin "@tailwindcss/typography"; - @custom-variant light (&:where(.light, .light *, [data-theme="light"], [data-theme="light"] *)); @custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *)); @custom-variant primary (&:where(.primary, .primary *)); @@ -137,77 +135,244 @@ } } -@layer utilities { +@layer components { /** - * Maps the typography plugin's palette onto the design tokens, so prose - * follows the theme instead of the plugin's stock grays. - * - * There is deliberately no `--tw-prose-invert-*` block: `[data-theme="dark"]` - * redefines the tokens below, so these values already flip with the theme. - * The invert palette only applies under the separate `prose-invert` class, - * which nothing here uses. - * - * Links map to `--foreground` rather than `--primary` on purpose — the `a` - * rule above distinguishes links by weight, not color. + * Prose styles — replaces @tailwindcss/typography. All rules are scoped so + * that `not-prose` descendants are unaffected. Colors read from the shadcn + * design tokens so light/dark switching is automatic. */ .prose { - --tw-prose-body: var(--foreground); - --tw-prose-headings: var(--primary); - --tw-prose-lead: var(--muted-foreground); - --tw-prose-links: var(--foreground); - --tw-prose-bold: var(--foreground); - --tw-prose-counters: var(--muted-foreground); - --tw-prose-bullets: var(--primary); - --tw-prose-hr: var(--border); - --tw-prose-quotes: var(--foreground); - --tw-prose-quote-borders: var(--border); - --tw-prose-captions: var(--muted-foreground); - --tw-prose-kbd: var(--foreground); - --tw-prose-kbd-shadows: color-mix(in oklab, var(--foreground) 10%, transparent); - --tw-prose-code: var(--foreground); - --tw-prose-pre-code: var(--card-foreground); - --tw-prose-pre-bg: var(--card); - --tw-prose-th-borders: var(--border); - --tw-prose-td-borders: var(--border); - } + @apply text-base leading-relaxed text-foreground; + max-width: 65ch; - .prose { - @apply text-foreground leading-normal; + /* headings */ + :where(h1, h2, h3, h4, h5, h6):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-mono font-bold text-foreground mt-8 mb-3 leading-tight; + } + + :where(h1):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-4xl; + } + + :where(h2):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-3xl; + } + + :where(h3):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-2xl font-semibold; + } + + :where(h4):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-xl font-semibold; + } + + :where(h5):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-lg font-medium; + } + + :where(h6):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-base font-medium; + } + + /* paragraphs */ + :where(p):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply my-5 leading-relaxed; + } + + /* lead paragraph */ + :where(.lead):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-xl text-muted-foreground; + } + + /* links */ + :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-semibold underline underline-offset-4 decoration-border transition-colors hover:decoration-primary; + } + + /* strong / em */ + :where(strong):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-bold text-foreground; + } + + :where(em):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply italic; + } + + /* lists */ + :where(ul):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply list-disc pl-6 my-5; + } + + :where(ol):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply list-decimal pl-6 my-5; + } + + :where(li):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply my-2 leading-relaxed; + } + + :where(ul > li):not(:where([class~='not-prose'], [class~='not-prose'] *))::marker { + color: var(--primary); + } + + :where(ol > li):not(:where([class~='not-prose'], [class~='not-prose'] *))::marker { + color: var(--muted-foreground); + } + + /* hr */ + :where(hr):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply border-border my-8; + } + + /* blockquote */ + :where(blockquote):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply border-l-2 border-primary pl-4 my-6 italic text-muted-foreground; + + p::before, + p::after { + content: none; + } + } + + /* inline code */ + :where(code):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-mono text-sm text-foreground bg-muted rounded px-1.5 py-0.5; + + &::before, + &::after { + content: none; + } + } + + /* pre / fenced code blocks */ + :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply bg-card text-card-foreground rounded-md border border-border overflow-x-auto my-6 text-sm; + + code { + @apply bg-transparent p-0 text-inherit rounded-none; + } + } + + /* figures */ + :where(figure):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply my-8; + } + + :where(figcaption):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-sm text-muted-foreground mt-2; + } + + /* images */ + :where(img, video):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply rounded-md; + } + + /* kbd */ + :where(kbd):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-mono text-sm text-foreground rounded border border-border px-1.5 py-0.5; + box-shadow: 0 1px 0 color-mix(in oklab, var(--foreground) 10%, transparent); + } + + /* tables */ + :where(table):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply w-full text-sm my-6; + } + + :where(thead):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply border-b border-border; + } - h2 { - @apply text-inherit text-4xl font-mono font-bold leading-normal mb-1; + :where(thead th):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-mono font-semibold text-foreground px-3 py-2 text-left; } - h3 { - @apply text-inherit text-3xl font-mono font-semibold leading-snug mb-0; + :where(tbody tr):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply border-b border-border last:border-0; } - h4 { - @apply text-inherit text-3xl font-bold leading-snug mb-0; + :where(tbody td):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply px-3 py-2 align-top; } - h5 { - @apply text-inherit text-2xl font-semibold leading-snug mb-0; + /* ── Lexical checklist ──────────────────────────────────────────────── */ + :where(ul.list-check):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + list-style: none; + padding-left: 0; } - h6 { - @apply text-inherit text-xl font-medium leading-snug mb-0; + :where(.list-item-checkbox):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply my-1 flex items-center gap-2 leading-relaxed; + + input[type='checkbox'] { + @apply h-4 w-4 shrink-0 cursor-default rounded accent-primary; + } + + label { + @apply cursor-default; + } } - :where(p):not(:where([class~='not-prose'], [class~='not-prose'] *)) p { - @apply text-inherit leading-normal my-4; + :where(.list-item-checkbox-checked):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + label { + @apply line-through text-muted-foreground; + } } - a { - @apply text-inherit font-semibold hover:underline; + /* nested list items (Lexical sets list-style-type:none inline) */ + :where(.nestedListItem):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + list-style: none; + + > ul, + > ol { + @apply my-1 pl-4; + } + } + + /* ── Lexical ordered-list depth cycling ─────────────────────────────── */ + :where(ol.list-number):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + list-style-type: decimal; + } + + :where(ol.list-number ol.list-number):not( + :where([class~='not-prose'], [class~='not-prose'] *) + ) { + list-style-type: lower-alpha; + } + + :where(ol.list-number ol.list-number ol.list-number):not( + :where([class~='not-prose'], [class~='not-prose'] *) + ) { + list-style-type: lower-roman; + } + + :where(ol.list-number ol.list-number ol.list-number ol.list-number):not( + :where([class~='not-prose'], [class~='not-prose'] *) + ) { + list-style-type: upper-alpha; + } + + :where(ol.list-number ol.list-number ol.list-number ol.list-number ol.list-number):not( + :where([class~='not-prose'], [class~='not-prose'] *) + ) { + list-style-type: upper-roman; + } + + /* ── Lexical text format spans ──────────────────────────────────────── */ + /* strikethrough and underline are applied as inline styles by the + Lexical converter, so no class rules are needed for those. */ + + /* sub/sup sizing adjustment */ + :where(sub, sup):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-xs; } - ul { - @apply list-disc pl-6; + /* first/last child spacing reset */ + :where(> :first-child):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + margin-top: 0; } - li { - @apply leading-snug my-3 first:mt-0 last:mb-0 marker:text-primary; + :where(> :last-child):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + margin-bottom: 0; } } } diff --git a/src/styles/payload.css b/src/styles/payload.css index 95732516..b576d5b2 100644 --- a/src/styles/payload.css +++ b/src/styles/payload.css @@ -16,8 +16,6 @@ @import './payload/_buttons.css' layer(payload-default); @import './payload/_list-header.css' layer(payload-default); -@plugin "@tailwindcss/typography"; - @custom-variant light (&:where(.light, .light *, [data-theme="light"], [data-theme="light"] *)); @custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *)); diff --git a/src/types/payload.ts b/src/types/payload.ts index 78e77afb..5b23917c 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -996,6 +996,10 @@ export interface Topic { * via the `definition` "TwoColumnContentBlock". */ export interface TwoColumnContentBlock { + /** + * Vertical alignment of the two columns relative to each other. + */ + verticalAlignment?: ('start' | 'center' | 'end') | null; contentLeft?: { root: { type: string; diff --git a/src/widgets/ScheduledJobsWidget/ScheduledJobsWidget.List.tsx b/src/widgets/ScheduledJobsWidget/ScheduledJobsWidget.List.tsx index 7950232f..fadb0c97 100644 --- a/src/widgets/ScheduledJobsWidget/ScheduledJobsWidget.List.tsx +++ b/src/widgets/ScheduledJobsWidget/ScheduledJobsWidget.List.tsx @@ -66,18 +66,20 @@ export const ScheduledJobsWidgetList = ({ jobs }: ScheduledJobsWidgetListProps) /> - {visibleJobs.length > 0 ? ( - visibleJobs.map((job) => ) - ) : ( -
- - - {selectedQueue ? `No scheduled jobs in "${selectedQueue}"` : 'No scheduled jobs'} - -
- )} +
+ {visibleJobs.length > 0 ? ( + visibleJobs.map((job) => ) + ) : ( +
+ + + {selectedQueue ? `No scheduled jobs in "${selectedQueue}"` : 'No scheduled jobs'} + +
+ )} +
) } diff --git a/src/widgets/StatusBannersWidget/StatusBannerItem.tsx b/src/widgets/StatusBannersWidget/StatusBannerItem.tsx index 86612e8a..976d5aa8 100644 --- a/src/widgets/StatusBannersWidget/StatusBannerItem.tsx +++ b/src/widgets/StatusBannersWidget/StatusBannerItem.tsx @@ -49,20 +49,19 @@ export const StatusBannerItem = ({ id, jobName, waitUntil }: StatusBannerItemPro } return ( - -
+ +
- + {startCase(jobName)}: detected as stale job - + Due {overdueLabel} — stuck in queue
-
+