chore: remove files importing pruned packages (ai, cms, cognitive-context)
Step 3 pruned packages/{ai,cms,cognitive-context} but left whole
route groups + feature modules that depended on them. Those files
were unbuildable since that prune. Removes them now so the workspace
can be validated:
Route groups:
- apps/web/src/app/[locale]/(apps)/{chat,image,pdf,tts}/
- apps/web/src/app/[locale]/(marketing)/blog/
Feature modules:
- apps/web/src/modules/{chat,image,pdf,tts,common/ai,marketing/blog}/
- packages/api/src/modules/ai/ (chat, image, pdf, stt, tts, router)
3 stragglers remain (separate handoff to claudemesh-2):
- apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx (cms)
- apps/web/src/app/sitemap.ts (cms)
- apps/web/src/modules/common/layout/credits/index.tsx (ai)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { messageSchema, partSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { toChatMessage } from "@turbostarter/ai/chat/utils";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { ViewChat } from "~/modules/chat/layout/view";
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) => {
|
||||
const id = (await params).id;
|
||||
const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return getMetadata({
|
||||
...(data?.name && { title: data.name }),
|
||||
})({ params });
|
||||
};
|
||||
|
||||
export default async function Chat({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
|
||||
if (!user) {
|
||||
return redirect(pathsConfig.auth.login);
|
||||
}
|
||||
|
||||
const id = (await params).id;
|
||||
|
||||
const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const messages = await handle(api.ai.chat.chats[":id"].messages.$get, {
|
||||
throwOnError: false,
|
||||
schema: z.array(
|
||||
messageSchema.extend({
|
||||
parts: z.array(partSchema),
|
||||
}),
|
||||
),
|
||||
})({
|
||||
param: { id },
|
||||
});
|
||||
const initialMessages = (messages ?? []).map(toChatMessage);
|
||||
|
||||
return <ViewChat id={id} initialMessages={initialMessages} />;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { ChatHistory } from "~/modules/chat/history";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:chat.title",
|
||||
description: "ai:chat.description",
|
||||
});
|
||||
|
||||
export default function ChatLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<ChatHistory />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
import { NewChat } from "~/modules/chat/layout/new";
|
||||
import { ViewChat } from "~/modules/chat/layout/view";
|
||||
|
||||
export default function Chat() {
|
||||
const id = useMemo(() => generateId(), []);
|
||||
|
||||
const { messages } = useComposer({
|
||||
id,
|
||||
});
|
||||
|
||||
if (messages.length) {
|
||||
return <ViewChat id={id} />;
|
||||
}
|
||||
|
||||
return <NewChat id={id} />;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { generationSchema } from "@turbostarter/ai/image/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { ViewGeneration } from "~/modules/image/generation/view";
|
||||
import { HistoryCta } from "~/modules/image/history/cta";
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) => {
|
||||
const id = (await params).id;
|
||||
const generation = await handle(api.ai.image.generations[":id"].$get)({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return getMetadata({
|
||||
...(generation?.prompt && {
|
||||
title:
|
||||
generation.prompt.length > 50
|
||||
? `${generation.prompt.slice(0, 50)}...`
|
||||
: generation.prompt,
|
||||
}),
|
||||
})({ params });
|
||||
};
|
||||
|
||||
export default async function ImageGeneration({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = (await params).id;
|
||||
|
||||
const generation = await handle(api.ai.image.generations[":id"].$get, {
|
||||
schema: generationSchema.nullable(),
|
||||
})({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
if (!generation) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const images = await handle(api.ai.image.generations[":id"].images.$get)({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<HistoryCta />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<ViewGeneration
|
||||
id={id}
|
||||
initialGeneration={{
|
||||
...generation,
|
||||
input: {
|
||||
prompt: generation.prompt,
|
||||
options: generation,
|
||||
},
|
||||
images: images.map((image) => ({
|
||||
url: image.url,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { History } from "~/modules/image/history";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:image.history.title",
|
||||
description: "ai:image.history.description",
|
||||
});
|
||||
|
||||
export default function HistoryPage() {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<History />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:image.title",
|
||||
description: "ai:image.description",
|
||||
});
|
||||
|
||||
export default function ImageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { NewGeneration } from "~/modules/image/generation/new";
|
||||
import { ViewGeneration } from "~/modules/image/generation/view";
|
||||
import { HistoryCta } from "~/modules/image/history/cta";
|
||||
import { useImageGeneration } from "~/modules/image/use-image-generation";
|
||||
|
||||
const Image = () => {
|
||||
const id = useMemo(() => generateId(), []);
|
||||
|
||||
const { generation } = useImageGeneration({
|
||||
id,
|
||||
});
|
||||
|
||||
if (generation) {
|
||||
return <ViewGeneration id={id} />;
|
||||
}
|
||||
|
||||
return <NewGeneration id={id} />;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header className="bg-transparent">
|
||||
<div className="flex items-center gap-1">
|
||||
<HistoryCta />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<Image />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { PdfLayout } from "~/modules/pdf/layout/layout";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = (await params).id;
|
||||
return <PdfLayout id={id}>{children}</PdfLayout>;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { messageSchema } from "@turbostarter/ai/pdf/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { ChatComposer } from "~/modules/pdf/composer";
|
||||
import { Chat } from "~/modules/pdf/thread";
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) => {
|
||||
const id = (await params).id;
|
||||
const chat = await handle(api.ai.pdf.chats[":id"].$get)({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return getMetadata({
|
||||
...(chat?.name && { title: chat.name }),
|
||||
})({ params });
|
||||
};
|
||||
|
||||
const PdfChat = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
const id = (await params).id;
|
||||
const messages = await handle(api.ai.pdf.chats[":id"].messages.$get, {
|
||||
schema: z.array(messageSchema),
|
||||
})({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
const initialMessages = messages.map((message) => ({
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: message.content,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat id={id} initialMessages={initialMessages} />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-200">
|
||||
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
|
||||
<ChatComposer id={id} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PdfChat;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:pdf.title",
|
||||
description: "ai:pdf.description",
|
||||
});
|
||||
|
||||
export default function PdfLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { RecentChats } from "~/modules/pdf/components/recent-chats";
|
||||
import { ChatHistory } from "~/modules/pdf/history";
|
||||
import { PdfUpload } from "~/modules/pdf/upload";
|
||||
|
||||
export default function PdfPage() {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<ChatHistory />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto p-3 pt-12 md:pt-14">
|
||||
<PdfUpload />
|
||||
<RecentChats />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:tts.title",
|
||||
description: "ai:tts.description",
|
||||
});
|
||||
|
||||
export default function AgentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header className="bg-transparent">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { getVoices } from "@turbostarter/ai/tts/api";
|
||||
|
||||
// Skip static generation - requires ELEVENLABS_API_KEY at runtime
|
||||
export const dynamic = "force-dynamic";
|
||||
import { random } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Tts } from "~/modules/tts";
|
||||
|
||||
const getCachedVoices = unstable_cache(
|
||||
async () => {
|
||||
const voices = await getVoices();
|
||||
|
||||
return voices.map((voice) => ({
|
||||
...voice,
|
||||
avatar: {
|
||||
src: `/images/avatars/${random(1, 3)}.webp`,
|
||||
style: {
|
||||
filter: `hue-rotate(${random(0, 360)}deg) saturate(1.2)`,
|
||||
transform: `rotate(${random(0, 360)}deg)`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
["voices"],
|
||||
{
|
||||
revalidate: 3600 * 24, // Cache for 1 day
|
||||
tags: ["voices"],
|
||||
},
|
||||
);
|
||||
|
||||
export default async function TtsPage() {
|
||||
const voices = await getCachedVoices();
|
||||
|
||||
return <Tts voices={voices} />;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getContentItemBySlug, getContentItems } from "@turbostarter/cms";
|
||||
import { CollectionType } from "@turbostarter/cms";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { badgeVariants } from "@turbostarter/ui-web/badge";
|
||||
|
||||
import { BLOG_PREFIX } from "~/config/paths";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Mdx } from "~/modules/common/mdx";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import {
|
||||
Section,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
}) {
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
const item = getContentItemBySlug({
|
||||
collection: CollectionType.BLOG,
|
||||
slug: (await params).slug,
|
||||
locale: (await params).locale,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionHeader className="max-w-3xl">
|
||||
<div className="mr-auto flex flex-wrap gap-1 md:gap-1.5">
|
||||
{item.tags.map((tag) => (
|
||||
<TurboLink
|
||||
key={tag}
|
||||
href={`${BLOG_PREFIX}?tag=${tag}`}
|
||||
className={badgeVariants({ variant: "outline" })}
|
||||
>
|
||||
{t(`blog.tag.${tag}`)}
|
||||
</TurboLink>
|
||||
))}
|
||||
</div>
|
||||
<SectionTitle as="h1" className="mt-2 text-left">
|
||||
{item.title}
|
||||
</SectionTitle>
|
||||
<div className="text-muted-foreground mr-auto flex flex-wrap items-center gap-1.5">
|
||||
<time
|
||||
className="text-muted-foreground"
|
||||
dateTime={item.publishedAt.toISOString()}
|
||||
>
|
||||
{dayjs(item.publishedAt).format("MMMM D, YYYY")}
|
||||
</time>
|
||||
|
||||
{item.timeToRead && <span>·</span>}
|
||||
{typeof item.timeToRead !== "undefined" && (
|
||||
<span>
|
||||
{t("blog.timeToRead", {
|
||||
time: Math.ceil(dayjs.duration(item.timeToRead).asMinutes()),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionDescription className="text-left">
|
||||
{item.description}
|
||||
</SectionDescription>
|
||||
|
||||
<div className="relative -mx-2 mt-4 aspect-[12/8] w-[calc(100%+1rem)]">
|
||||
<Image
|
||||
alt=""
|
||||
fill
|
||||
src={item.thumbnail}
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
<Mdx mdx={item.mdx} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getContentItems({ collection: CollectionType.BLOG }).items.map(
|
||||
(post) => ({
|
||||
slug: post.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
}) {
|
||||
const item = getContentItemBySlug({
|
||||
collection: CollectionType.BLOG,
|
||||
slug: (await params).slug,
|
||||
locale: (await params).locale,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return getMetadata({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
})({ params });
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import Image from "next/image";
|
||||
|
||||
import {
|
||||
CollectionType,
|
||||
ContentStatus,
|
||||
getContentItems,
|
||||
} from "@turbostarter/cms";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { SortOrder } from "@turbostarter/shared/constants";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { TagsPicker } from "~/modules/marketing/blog/tags-picker";
|
||||
import {
|
||||
Section,
|
||||
SectionBadge,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
import type { ContentTag } from "@turbostarter/cms";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "marketing:blog.label",
|
||||
description: "marketing:blog.description",
|
||||
canonical: pathsConfig.marketing.blog.index,
|
||||
});
|
||||
|
||||
export default async function BlogPage({
|
||||
searchParams,
|
||||
params,
|
||||
}: {
|
||||
searchParams: Promise<{ tag?: ContentTag }>;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const tag = (await searchParams).tag;
|
||||
const locale = (await params).locale;
|
||||
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
const { items } = getContentItems({
|
||||
collection: CollectionType.BLOG,
|
||||
tags: tag ? [tag] : [],
|
||||
sortBy: "publishedAt",
|
||||
sortOrder: SortOrder.DESCENDING,
|
||||
status: ContentStatus.PUBLISHED,
|
||||
locale,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionHeader className="flex flex-col items-center justify-center gap-3">
|
||||
<SectionBadge>{t("blog.label")}</SectionBadge>
|
||||
<SectionTitle as="h1">{t("blog.title")}</SectionTitle>
|
||||
<SectionDescription>{t("blog.description")}</SectionDescription>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="-mt-2 sm:-mt-4 md:-mt-6 lg:-mt-10">
|
||||
<TagsPicker />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-start justify-center gap-x-6 gap-y-8 md:grid-cols-2 md:gap-y-12 lg:grid-cols-3 lg:gap-y-16">
|
||||
{items.map((post) => (
|
||||
<TurboLink
|
||||
key={post.slug}
|
||||
href={pathsConfig.marketing.blog.post(post.slug)}
|
||||
className="group h-full basis-[34rem]"
|
||||
>
|
||||
<Card className="group-hover:bg-muted/50 h-full border-none shadow-none">
|
||||
<CardHeader className="space-y-2 p-3 pb-2">
|
||||
<div className="bg-muted -mx-3 -mt-3 mb-3 aspect-[12/8] overflow-hidden rounded-lg">
|
||||
<div className="relative h-full w-full transition-transform duration-300 group-hover:scale-105">
|
||||
<Image
|
||||
alt=""
|
||||
fill
|
||||
src={post.thumbnail}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 pb-1">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{t(`blog.tag.${tag}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">{post.title}</CardTitle>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<time dateTime={post.publishedAt.toISOString()}>
|
||||
{dayjs(post.publishedAt).format("MMMM D, YYYY")}
|
||||
</time>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t("blog.timeToRead", {
|
||||
time: Math.ceil(
|
||||
dayjs.duration(post.timeToRead).asMinutes(),
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-3 pt-0">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{post.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TurboLink>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user