This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+282
-220
@@ -1,251 +1,313 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { postComment } from "@/actions/article-comments";
|
||||
import { toggleReaction } from "@/actions/article-reactions";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { postComment } from "@/actions/article-comments";
|
||||
import { toggleReaction } from "@/actions/article-reactions";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const { slug } = await params;
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({ where: { slug }, select: { title: true, shortStory: true } })
|
||||
.catch(() => null);
|
||||
if (!article) return { title: "Article" };
|
||||
return {
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
alternates: { canonical: `${appUrl}/news/${slug}` },
|
||||
openGraph: {
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
type: "article",
|
||||
url: `${appUrl}/news/${slug}`,
|
||||
},
|
||||
twitter: { card: "summary", title: article.title },
|
||||
};
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const { slug } = await params;
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({ where: { slug }, select: { title: true, shortStory: true } })
|
||||
.catch(() => null);
|
||||
if (!article) return { title: "Article" };
|
||||
return {
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
alternates: { canonical: `${appUrl}/news/${slug}` },
|
||||
openGraph: {
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
type: "article",
|
||||
url: `${appUrl}/news/${slug}`,
|
||||
},
|
||||
twitter: { card: "summary", title: article.title },
|
||||
};
|
||||
}
|
||||
|
||||
// The reaction set offered by the voting UI. Must stay in sync with
|
||||
// ALLOWED_REACTIONS in src/actions/article-reactions.ts.
|
||||
const REACTIONS: { key: string; emoji: string }[] = [
|
||||
{ key: "like", emoji: "👍" },
|
||||
{ key: "love", emoji: "❤️" },
|
||||
{ key: "wow", emoji: "😮" },
|
||||
{ key: "like", emoji: "👍" },
|
||||
{ key: "love", emoji: "❤️" },
|
||||
{ key: "wow", emoji: "😮" },
|
||||
];
|
||||
|
||||
function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const t = await getTranslations("pages.article");
|
||||
export default async function ArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
shortStory: true,
|
||||
fullStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!article) notFound();
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
shortStory: true,
|
||||
fullStory: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!article) notFound();
|
||||
|
||||
const articleId = article.id;
|
||||
const articleId = article.id;
|
||||
|
||||
const session = await auth();
|
||||
const loggedIn = Boolean(session?.user?.id);
|
||||
const sessionUserId = session?.user?.id ? Number(session.user.id) : null;
|
||||
const session = await auth();
|
||||
const loggedIn = Boolean(session?.user?.id);
|
||||
const sessionUserId = session?.user?.id ? Number(session.user.id) : null;
|
||||
|
||||
// Reaction counts grouped by reaction type for this article, plus the
|
||||
// signed-in user's currently-active reaction (so its button reads as pressed).
|
||||
let reactionCounts = new Map<string, number>();
|
||||
let myReaction: string | null = null;
|
||||
try {
|
||||
const rows = await prisma.websiteArticleReactions.findMany({
|
||||
where: { articleId, active: true },
|
||||
select: { reaction: true, userId: true },
|
||||
});
|
||||
for (const r of rows) {
|
||||
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
|
||||
if (sessionUserId !== null && r.userId === sessionUserId) myReaction = r.reaction;
|
||||
}
|
||||
} catch {
|
||||
reactionCounts = new Map();
|
||||
myReaction = null;
|
||||
}
|
||||
// Reaction counts grouped by reaction type for this article, plus the
|
||||
// signed-in user's currently-active reaction (so its button reads as pressed).
|
||||
let reactionCounts = new Map<string, number>();
|
||||
let myReaction: string | null = null;
|
||||
try {
|
||||
const rows = await prisma.websiteArticleReactions.findMany({
|
||||
where: { articleId, active: true },
|
||||
select: { reaction: true, userId: true },
|
||||
});
|
||||
for (const r of rows) {
|
||||
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
|
||||
if (sessionUserId !== null && r.userId === sessionUserId)
|
||||
myReaction = r.reaction;
|
||||
}
|
||||
} catch {
|
||||
reactionCounts = new Map();
|
||||
myReaction = null;
|
||||
}
|
||||
|
||||
// Comments for this article (oldest first).
|
||||
let comments: Awaited<ReturnType<typeof prisma.websiteArticleComments.findMany>> = [];
|
||||
try {
|
||||
comments = await prisma.websiteArticleComments.findMany({
|
||||
where: { articleId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
} catch {
|
||||
comments = [];
|
||||
}
|
||||
// Comments for this article (oldest first).
|
||||
let comments: Awaited<
|
||||
ReturnType<typeof prisma.websiteArticleComments.findMany>
|
||||
> = [];
|
||||
try {
|
||||
comments = await prisma.websiteArticleComments.findMany({
|
||||
where: { articleId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
} catch {
|
||||
comments = [];
|
||||
}
|
||||
|
||||
// Resolve usernames for the commenters (userId is the emulator users.id, Int).
|
||||
const commenterIds = Array.from(new Set(comments.map((c) => c.userId)));
|
||||
let usernameById = new Map<number, string>();
|
||||
if (commenterIds.length > 0) {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: commenterIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
} catch {
|
||||
usernameById = new Map();
|
||||
}
|
||||
}
|
||||
// Resolve usernames for the commenters (userId is the emulator users.id, Int).
|
||||
const commenterIds = Array.from(new Set(comments.map((c) => c.userId)));
|
||||
let usernameById = new Map<number, string>();
|
||||
if (commenterIds.length > 0) {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: commenterIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
} catch {
|
||||
usernameById = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title={article.title}
|
||||
subtitle={article.createdAt ? article.createdAt.toISOString().slice(0, 10) : undefined}
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
{t("backToNews")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{article.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={article.image} alt="" style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }} />
|
||||
) : null}
|
||||
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised article body */}
|
||||
<div className="article-body" dangerouslySetInnerHTML={{ __html: sanitize(article.fullStory) }} />
|
||||
</ContentCard>
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title={article.title}
|
||||
subtitle={
|
||||
article.createdAt
|
||||
? article.createdAt.toISOString().slice(0, 10)
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
{t("backToNews")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{article.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.image}
|
||||
alt=""
|
||||
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
||||
/>
|
||||
) : null}
|
||||
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised article body */}
|
||||
<div
|
||||
className="article-body"
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(article.fullStory) }}
|
||||
/>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Reactions ─────────────────────────────────────────── */}
|
||||
<ContentCard icon="💬" title={t("reactionsTitle")} subtitle={t("reactionsSubtitle")}>
|
||||
{loggedIn ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{REACTIONS.map((r) => {
|
||||
const active = myReaction === r.key;
|
||||
const count = reactionCounts.get(r.key) ?? 0;
|
||||
const label = `${r.emoji} ${t(`reaction.${r.key}`)}`;
|
||||
return (
|
||||
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
|
||||
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||
<input type="hidden" name="reaction" value={r.key} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-pressed={active}
|
||||
className={`btn ${active ? "btn-primary" : "btn-outline"}`}
|
||||
title={active ? t("removeReaction") : t("reactWith", { reaction: r.key })}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="muted" style={{ fontSize: "0.85rem", color: "inherit", opacity: 0.85 }}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : reactionCounts.size === 0 ? (
|
||||
<EmptyState icon="💬">
|
||||
{t("noReactions")} <Link href="/login">{t("logIn")}</Link> {t("toReact")}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{REACTIONS.filter((r) => (reactionCounts.get(r.key) ?? 0) > 0).map((r) => (
|
||||
<span key={r.key} className="currency">
|
||||
<span style={{ fontWeight: 800 }}>{`${r.emoji} ${t(`reaction.${r.key}`)}`}</span>
|
||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{reactionCounts.get(r.key) ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: "0.6rem" }}>
|
||||
<Link href="/login">{t("logIn")}</Link> {t("toReact")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
{/* ── Reactions ─────────────────────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="💬"
|
||||
title={t("reactionsTitle")}
|
||||
subtitle={t("reactionsSubtitle")}
|
||||
>
|
||||
{loggedIn ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{REACTIONS.map((r) => {
|
||||
const active = myReaction === r.key;
|
||||
const count = reactionCounts.get(r.key) ?? 0;
|
||||
const label = `${r.emoji} ${t(`reaction.${r.key}`)}`;
|
||||
return (
|
||||
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="articleId"
|
||||
value={String(articleId)}
|
||||
/>
|
||||
<input type="hidden" name="reaction" value={r.key} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-pressed={active}
|
||||
className={`btn ${active ? "btn-primary" : "btn-outline"}`}
|
||||
title={
|
||||
active
|
||||
? t("removeReaction")
|
||||
: t("reactWith", { reaction: r.key })
|
||||
}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "inherit",
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : reactionCounts.size === 0 ? (
|
||||
<EmptyState icon="💬">
|
||||
{t("noReactions")} <Link href="/login">{t("logIn")}</Link>{" "}
|
||||
{t("toReact")}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{REACTIONS.filter(
|
||||
(r) => (reactionCounts.get(r.key) ?? 0) > 0,
|
||||
).map((r) => (
|
||||
<span key={r.key} className="currency">
|
||||
<span
|
||||
style={{ fontWeight: 800 }}
|
||||
>{`${r.emoji} ${t(`reaction.${r.key}`)}`}</span>
|
||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{reactionCounts.get(r.key) ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: "0.6rem" }}>
|
||||
<Link href="/login">{t("logIn")}</Link> {t("toReact")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Comments ──────────────────────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🗨️"
|
||||
title={t("commentsTitle")}
|
||||
subtitle={
|
||||
comments.length > 0 ? t("commentsCount", { count: comments.length }) : t("commentsSubtitle")
|
||||
}
|
||||
>
|
||||
{comments.length === 0 ? (
|
||||
<EmptyState icon="🗨️">{t("noComments")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid" style={{ marginBottom: "1.5rem" }}>
|
||||
{comments.map((c) => {
|
||||
const name = usernameById.get(c.userId);
|
||||
return (
|
||||
<article key={String(c.id)} className="card">
|
||||
<p className="muted" style={{ marginTop: 0, marginBottom: "0.35rem" }}>
|
||||
{name ? (
|
||||
<Link href={`/u/${name}`}>{name}</Link>
|
||||
) : (
|
||||
<span>{t("userNumber", { id: c.userId })}</span>
|
||||
)}
|
||||
{c.createdAt ? <span> · {formatDate(c.createdAt)}</span> : null}
|
||||
</p>
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>{c.comment}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* ── Comments ──────────────────────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🗨️"
|
||||
title={t("commentsTitle")}
|
||||
subtitle={
|
||||
comments.length > 0
|
||||
? t("commentsCount", { count: comments.length })
|
||||
: t("commentsSubtitle")
|
||||
}
|
||||
>
|
||||
{comments.length === 0 ? (
|
||||
<EmptyState icon="🗨️">{t("noComments")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid" style={{ marginBottom: "1.5rem" }}>
|
||||
{comments.map((c) => {
|
||||
const name = usernameById.get(c.userId);
|
||||
return (
|
||||
<article key={String(c.id)} className="card">
|
||||
<p
|
||||
className="muted"
|
||||
style={{ marginTop: 0, marginBottom: "0.35rem" }}
|
||||
>
|
||||
{name ? (
|
||||
<Link href={`/u/${name}`}>{name}</Link>
|
||||
) : (
|
||||
<span>{t("userNumber", { id: c.userId })}</span>
|
||||
)}
|
||||
{c.createdAt ? (
|
||||
<span> · {formatDate(c.createdAt)}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{c.comment}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loggedIn ? (
|
||||
<form action={postComment} className="card">
|
||||
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||
<label htmlFor="comment" style={{ display: "block", fontWeight: 700, marginBottom: "0.5rem" }}>
|
||||
{t("addComment")}
|
||||
</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("commentPlaceholder")}
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("postComment")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">
|
||||
<Link href="/login">{t("logIn")}</Link> {t("toComment")}
|
||||
</p>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
{loggedIn ? (
|
||||
<form action={postComment} className="card">
|
||||
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||
<label
|
||||
htmlFor="comment"
|
||||
style={{
|
||||
display: "block",
|
||||
fontWeight: 700,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("addComment")}
|
||||
</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("commentPlaceholder")}
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("postComment")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">
|
||||
<Link href="/login">{t("logIn")}</Link> {t("toComment")}
|
||||
</p>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
export default function NewsLoading() {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{ borderColor: "var(--color-primary)", borderTopColor: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+106
-80
@@ -1,6 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -10,88 +10,114 @@ export const revalidate = 300;
|
||||
export const metadata = { title: "News" };
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export default async function NewsPage() {
|
||||
const t = await getTranslations("pages.news");
|
||||
const t = await getTranslations("pages.news");
|
||||
|
||||
let articles: Awaited<ReturnType<typeof prisma.websiteArticles.findMany>> = [];
|
||||
try {
|
||||
articles = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — render the empty state rather than a 500.
|
||||
articles = [];
|
||||
}
|
||||
let articles: Awaited<ReturnType<typeof prisma.websiteArticles.findMany>> =
|
||||
[];
|
||||
try {
|
||||
articles = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — render the empty state rather than a 500.
|
||||
articles = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📰" title={t("title")} subtitle={t("subtitle")} />
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📰" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={articles.length === 0}>
|
||||
{articles.length === 0 ? (
|
||||
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{articles.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
textDecoration: "none",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{a.image ? (
|
||||
<div style={{ position: "relative", width: "100%", aspectRatio: "16/9" }}>
|
||||
<Image src={a.image} alt="" fill style={{ objectFit: "cover" }} unoptimized />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
backgroundColor: "color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ padding: "1rem 1.1rem", flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.05rem",
|
||||
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{a.title}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: "0.35rem 0 0.6rem", fontSize: "0.78rem" }}>
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
|
||||
fontSize: "0.88rem",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{excerpt(a.shortStory, 150)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
<ContentCard padded={articles.length === 0}>
|
||||
{articles.length === 0 ? (
|
||||
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{articles.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
textDecoration: "none",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{a.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={a.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.1rem",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.05rem",
|
||||
color:
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text))",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{a.title}
|
||||
</h3>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0.35rem 0 0.6rem", fontSize: "0.78rem" }}
|
||||
>
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color:
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text))",
|
||||
fontSize: "0.88rem",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{excerpt(a.shortStory, 150)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user