fix(news): update slug page types
This commit is contained in:
1 parent
b06f27ef89
commit
83884ef2e3
1 file changed
+399
@@ -0,0 +1,399 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { postComment } from "@/actions/article-comments";
|
||||||
|
import { toggleReaction } from "@/actions/article-reactions";
|
||||||
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { cachedQuery } from "@/lib/cached-db";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
User,
|
||||||
|
WebsiteArticleComments,
|
||||||
|
WebsiteArticleReactions,
|
||||||
|
WebsiteArticles,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { excerpt } from "@/lib/format";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
|
||||||
|
// TODO: Cache Components adoption. Refactor this route so this opt-out can be removed.
|
||||||
|
// See: https://nextjs.org/docs/app/guides/migrating-to-cache-components
|
||||||
|
export const instant = false;
|
||||||
|
|
||||||
|
|
||||||
|
type SearchParams = Promise<{
|
||||||
|
comment?: string;
|
||||||
|
reaction?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||||
|
const accent =
|
||||||
|
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||||
|
return {
|
||||||
|
margin: 0,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${accent}`,
|
||||||
|
color: "var(--color-text-readable, var(--color-text))",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderLeft: `4px solid ${accent}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 db
|
||||||
|
.select({
|
||||||
|
title: WebsiteArticles.title,
|
||||||
|
shortStory: WebsiteArticles.shortStory,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.slug, slug))
|
||||||
|
.limit(1)
|
||||||
|
.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: "😮" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function ArticlePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: SearchParams;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { comment, reaction, error } = await searchParams;
|
||||||
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
|
const article = await cachedQuery(
|
||||||
|
`article:slug:${slug}`,
|
||||||
|
async () => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: WebsiteArticles.id,
|
||||||
|
slug: WebsiteArticles.slug,
|
||||||
|
title: WebsiteArticles.title,
|
||||||
|
shortStory: WebsiteArticles.shortStory,
|
||||||
|
fullStory: WebsiteArticles.fullStory,
|
||||||
|
image: WebsiteArticles.image,
|
||||||
|
createdAt: WebsiteArticles.createdAt,
|
||||||
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.slug, slug))
|
||||||
|
.limit(1)
|
||||||
|
.catch(() => null);
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
if (!article) notFound();
|
||||||
|
|
||||||
|
const articleId = article.id;
|
||||||
|
|
||||||
|
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 db
|
||||||
|
.select({
|
||||||
|
reaction: WebsiteArticleReactions.reaction,
|
||||||
|
userId: WebsiteArticleReactions.userId,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticleReactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticleReactions.articleId, articleId),
|
||||||
|
eq(WebsiteArticleReactions.active, 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: (typeof WebsiteArticleComments.$inferSelect)[] = [];
|
||||||
|
try {
|
||||||
|
comments = await db
|
||||||
|
.select()
|
||||||
|
.from(WebsiteArticleComments)
|
||||||
|
.where(eq(WebsiteArticleComments.articleId, articleId))
|
||||||
|
.orderBy(asc(WebsiteArticleComments.createdAt))
|
||||||
|
.limit(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 db
|
||||||
|
.select({ id: User.id, username: User.username })
|
||||||
|
.from(User)
|
||||||
|
.where(inArray(User.id, commenterIds));
|
||||||
|
usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
} catch {
|
||||||
|
usernameById = new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error === "empty"
|
||||||
|
? t("errors.empty")
|
||||||
|
: error === "moderated"
|
||||||
|
? t("errors.moderated")
|
||||||
|
: error === "ratelimit"
|
||||||
|
? t("errors.ratelimit")
|
||||||
|
: error === "invalid"
|
||||||
|
? t("errors.invalid")
|
||||||
|
: error === "not_found"
|
||||||
|
? t("errors.notFound")
|
||||||
|
: error
|
||||||
|
? t("errors.error")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
{comment === "posted" ? (
|
||||||
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
|
{t("success.posted")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{reaction === "1" ? (
|
||||||
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
|
{t("success.reaction")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{errorMessage ? (
|
||||||
|
<div role="alert" style={feedbackStyle("error")}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ContentCard
|
||||||
|
icon="📰"
|
||||||
|
title={article.title}
|
||||||
|
subtitle={
|
||||||
|
article.createdAt ? formatDate(article.createdAt, "date") : 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. */}
|
||||||
|
<SanitizedHtml
|
||||||
|
html={sanitize(article.fullStory)}
|
||||||
|
className="article-body"
|
||||||
|
/>
|
||||||
|
</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="slug" value={article.slug} />
|
||||||
|
<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, "date", "")}</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)} />
|
||||||
|
<input type="hidden" name="slug" value={slug} />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user