Revert "Unify workspace dropdowns; Update layouts (#458)"

This reverts commit 4bc8448a1d.
This commit is contained in:
gbuomprisco
2026-03-11 14:47:47 +08:00
parent 4bc8448a1d
commit 4912e402a3
530 changed files with 11182 additions and 14382 deletions

View File

@@ -0,0 +1,110 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createCmsClient } from '@kit/cms';
import { withI18n } from '~/lib/i18n/with-i18n';
import { ChangelogDetail } from '../_components/changelog-detail';
interface ChangelogEntryPageProps {
params: Promise<{ slug: string }>;
}
const getChangelogData = cache(changelogEntryLoader);
async function changelogEntryLoader(slug: string) {
const client = await createCmsClient();
const [entry, allEntries] = await Promise.all([
client.getContentItemBySlug({ slug, collection: 'changelog' }),
client.getContentItems({
collection: 'changelog',
sortBy: 'publishedAt',
sortDirection: 'desc',
content: false,
}),
]);
if (!entry) {
return null;
}
// Find previous and next entries in the timeline
const currentIndex = allEntries.items.findIndex((item) => item.slug === slug);
const newerEntry =
currentIndex > 0 ? allEntries.items[currentIndex - 1] : null;
const olderEntry =
currentIndex < allEntries.items.length - 1
? allEntries.items[currentIndex + 1]
: null;
return {
entry,
previousEntry: olderEntry,
nextEntry: newerEntry,
};
}
export async function generateMetadata({
params,
}: ChangelogEntryPageProps): Promise<Metadata> {
const slug = (await params).slug;
const data = await getChangelogData(slug);
if (!data) {
notFound();
}
const { title, publishedAt, description, image } = data.entry;
return Promise.resolve({
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime: publishedAt,
url: data.entry.url,
images: image
? [
{
url: image,
},
]
: [],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: image ? [image] : [],
},
});
}
async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) {
const slug = (await params).slug;
const data = await getChangelogData(slug);
if (!data) {
notFound();
}
return (
<div className="container sm:max-w-none sm:p-0">
<ChangelogDetail
entry={data.entry}
content={data.entry.content}
previousEntry={data.previousEntry ?? null}
nextEntry={data.nextEntry ?? null}
/>
</div>
);
}
export default withI18n(ChangelogEntryPage);

View File

@@ -0,0 +1,36 @@
import type { Cms } from '@kit/cms';
import { ContentRenderer } from '@kit/cms';
import { ChangelogHeader } from './changelog-header';
import { ChangelogNavigation } from './changelog-navigation';
interface ChangelogDetailProps {
entry: Cms.ContentItem;
content: unknown;
previousEntry: Cms.ContentItem | null;
nextEntry: Cms.ContentItem | null;
}
export function ChangelogDetail({
entry,
content,
previousEntry,
nextEntry,
}: ChangelogDetailProps) {
return (
<div>
<ChangelogHeader entry={entry} />
<div className="mx-auto flex max-w-3xl flex-col space-y-6 py-8">
<article className="markdoc">
<ContentRenderer content={content} />
</article>
</div>
<ChangelogNavigation
previousEntry={previousEntry}
nextEntry={nextEntry}
/>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import Link from 'next/link';
import { type Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
import { DateBadge } from './date-badge';
interface ChangelogEntryProps {
entry: Cms.ContentItem;
highlight?: boolean;
}
export function ChangelogEntry({
entry,
highlight = false,
}: ChangelogEntryProps) {
const { title, slug, publishedAt, description } = entry;
const entryUrl = `/changelog/${slug}`;
return (
<div className="flex gap-6 md:gap-8">
<div className="md:border-border relative flex flex-1 flex-col space-y-0 gap-y-2.5 border-l border-dashed border-transparent pb-4 md:pl-8 lg:pl-12">
{highlight ? (
<span className="absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 md:flex">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-red-400"></span>
</span>
) : (
<div
className={cn(
'bg-muted absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 rounded-full md:block',
)}
/>
)}
<div className="hover:bg-muted/50 active:bg-muted rounded-md transition-colors">
<Link href={entryUrl} className="block space-y-2 p-4">
<div>
<DateBadge date={publishedAt} />
</div>
<h3 className="text-xl leading-tight font-semibold tracking-tight group-hover/link:underline">
{title}
</h3>
<If condition={description}>
{(desc) => (
<p className="text-muted-foreground text-sm leading-relaxed">
{desc}
</p>
)}
</If>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { CoverImage } from '../../blog/_components/cover-image';
import { DateFormatter } from '../../blog/_components/date-formatter';
export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) {
const { title, publishedAt, description, image } = entry;
return (
<div className="flex flex-1 flex-col">
<div className="border-border/50 border-b py-4">
<div className="mx-auto flex max-w-3xl items-center justify-between">
<Link
href="/changelog"
className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<Trans i18nKey="marketing:changelog" />
</Link>
</div>
</div>
<div className={cn('border-border/50 border-b py-8')}>
<div className="mx-auto flex max-w-3xl flex-col gap-y-2.5">
<div>
<span className="text-muted-foreground text-xs">
<DateFormatter dateString={publishedAt} />
</span>
</div>
<h1 className="font-heading text-2xl font-medium tracking-tighter xl:text-4xl dark:text-white">
{title}
</h1>
{description && (
<h2
className="text-muted-foreground text-base"
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
</div>
</div>
<If condition={image}>
{(imageUrl) => (
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
<CoverImage
preloadImage
className="rounded-md"
title={title}
src={imageUrl}
/>
</div>
)}
</If>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { DateFormatter } from '../../blog/_components/date-formatter';
interface ChangelogNavigationProps {
previousEntry: Cms.ContentItem | null;
nextEntry: Cms.ContentItem | null;
}
interface NavLinkProps {
entry: Cms.ContentItem;
direction: 'previous' | 'next';
}
function NavLink({ entry, direction }: NavLinkProps) {
const isPrevious = direction === 'previous';
const Icon = isPrevious ? ChevronLeft : ChevronRight;
const i18nKey = isPrevious
? 'marketing:changelogNavigationPrevious'
: 'marketing:changelogNavigationNext';
return (
<Link
href={`/changelog/${entry.slug}`}
className={cn(
'border-border/50 hover:bg-muted/50 group flex flex-col gap-2 rounded-lg border p-4 transition-all',
!isPrevious && 'text-right md:items-end',
)}
>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
{isPrevious && <Icon className="h-3 w-3" />}
<span className="font-medium tracking-wider uppercase">
<Trans i18nKey={i18nKey} />
</span>
{!isPrevious && <Icon className="h-3 w-3" />}
</div>
<div className="space-y-1">
<h3 className="group-hover:text-primary text-sm leading-tight font-semibold transition-colors">
{entry.title}
</h3>
<div className="text-muted-foreground text-xs">
<DateFormatter dateString={entry.publishedAt} />
</div>
</div>
</Link>
);
}
export function ChangelogNavigation({
previousEntry,
nextEntry,
}: ChangelogNavigationProps) {
return (
<div className="border-border/50 border-t py-8">
<div className="mx-auto max-w-3xl">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<If condition={previousEntry} fallback={<div />}>
{(prev) => <NavLink entry={prev} direction="previous" />}
</If>
<If condition={nextEntry} fallback={<div />}>
{(next) => <NavLink entry={next} direction="next" />}
</If>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
interface ChangelogPaginationProps {
currentPage: number;
canGoToNextPage: boolean;
canGoToPreviousPage: boolean;
}
export function ChangelogPagination({
currentPage,
canGoToNextPage,
canGoToPreviousPage,
}: ChangelogPaginationProps) {
const nextPage = currentPage + 1;
const previousPage = currentPage - 1;
return (
<div className="flex justify-end gap-2">
{canGoToPreviousPage && (
<Button asChild variant="outline" size="sm">
<Link href={`/changelog?page=${previousPage}`}>
<ArrowLeft className="mr-2 h-3 w-3" />
<span>
<Trans i18nKey="marketing:changelogPaginationPrevious" />
</span>
</Link>
</Button>
)}
{canGoToNextPage && (
<Button asChild variant="outline" size="sm">
<Link href={`/changelog?page=${nextPage}`}>
<span>
<Trans i18nKey="marketing:changelogPaginationNext" />
</span>
<ArrowRight className="ml-2 h-3 w-3" />
</Link>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
interface DateBadgeProps {
date: string;
}
export function DateBadge({ date }: DateBadgeProps) {
const formattedDate = format(new Date(date), 'MMMM d, yyyy');
return (
<div className="text-muted-foreground flex flex-shrink-0 items-center gap-2 text-sm">
<CalendarIcon className="size-3" />
<span>{formattedDate}</span>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { cache } from 'react';
import type { Metadata } from 'next';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../_components/site-page-header';
import { ChangelogEntry } from './_components/changelog-entry';
import { ChangelogPagination } from './_components/changelog-pagination';
interface ChangelogPageProps {
searchParams: Promise<{ page?: string }>;
}
const CHANGELOG_ENTRIES_PER_PAGE = 50;
export const generateMetadata = async (
props: ChangelogPageProps,
): Promise<Metadata> => {
const { t, resolvedLanguage } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const limit = CHANGELOG_ENTRIES_PER_PAGE;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const offset = page * limit;
const { total } = await getContentItems(resolvedLanguage, limit, offset);
return {
title: t('marketing:changelog'),
description: t('marketing:changelogSubtitle'),
pagination: {
previous: page > 0 ? `/changelog?page=${page - 1}` : undefined,
next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined,
},
};
};
const getContentItems = cache(
async (language: string | undefined, limit: number, offset: number) => {
const client = await createCmsClient();
const logger = await getLogger();
try {
return await client.getContentItems({
collection: 'changelog',
limit,
offset,
content: false,
language,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
} catch (error) {
logger.error({ error }, 'Failed to load changelog entries');
return { total: 0, items: [] };
}
},
);
async function ChangelogPage(props: ChangelogPageProps) {
const { t, resolvedLanguage: language } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const limit = CHANGELOG_ENTRIES_PER_PAGE;
const page = searchParams.page ? parseInt(searchParams.page) : 0;
const offset = page * limit;
const { total, items: entries } = await getContentItems(
language,
limit,
offset,
);
return (
<>
<SitePageHeader
title={t('marketing:changelog')}
subtitle={t('marketing:changelogSubtitle')}
/>
<div className="container flex max-w-4xl flex-col space-y-12 py-12">
<If
condition={entries.length > 0}
fallback={<Trans i18nKey="marketing:noChangelogEntries" />}
>
<div className="space-y-0">
{entries.map((entry, index) => {
return (
<ChangelogEntry
key={entry.id}
entry={entry}
highlight={index === 0}
/>
);
})}
</div>
<ChangelogPagination
currentPage={page}
canGoToNextPage={offset + limit < total}
canGoToPreviousPage={page > 0}
/>
</If>
</div>
</>
);
}
export default withI18n(ChangelogPage);