fix(public): distinguish unavailable data from empty results and missing pages
CI / check (push) Successful in 56s
CI / deploy (push) Successful in 1m30s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-11 00:39:53 +02:00
1 parent db4acbb46e
commit 445ef13846
16 files changed
+608 -169

No files matched your search

@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { replyToThread } from "@/actions/social";
import Link from "@/components/link";
import { PublicLoadError } from "@/components/public/load-error";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import {
@@ -14,6 +15,7 @@ import {
User,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { publicReadFailure } from "@/lib/services/public-read";
type SearchParams = Promise<{ replied?: string; error?: string }>;
@@ -60,6 +62,8 @@ export default async function GuildForumThreadPage({
pinned: number | null;
openerId: number | null;
} | null = null;
let postsFailed = false;
let authorsFailed = false;
let posts: {
id: number;
userId: number;
@@ -96,9 +100,15 @@ export default async function GuildForumThreadPage({
]);
guild = guildRow;
thread = threadRow;
} catch {
guild = null;
thread = null;
} catch (error) {
publicReadFailure("guild.thread")(error);
return (
<main className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} />
</ContentCard>
</main>
);
}
if (!guild || !thread) notFound();
@@ -122,7 +132,9 @@ export default async function GuildForumThreadPage({
asc(GuildsForumsComments.createdAt),
asc(GuildsForumsComments.id),
);
} catch {
} catch (error) {
publicReadFailure("guild.posts")(error);
postsFailed = true;
posts = [];
}
@@ -137,7 +149,9 @@ export default async function GuildForumThreadPage({
.from(User)
.where(inArray(User.id, authorIds))
: [];
} catch {
} catch (error) {
publicReadFailure("guild.post-authors")(error);
authorsFailed = true;
users = [];
}
const usernameById = new Map(users.map((u) => [u.id, u.username]));
@@ -195,10 +209,17 @@ export default async function GuildForumThreadPage({
<ContentCard
icon="📝"
title={t("postsTitle")}
subtitle={t("postsSubtitle", { count: posts.length })}
padded={posts.length === 0}
subtitle={
postsFailed ? undefined : t("postsSubtitle", { count: posts.length })
}
padded={posts.length === 0 || authorsFailed}
>
{posts.length === 0 ? (
{authorsFailed && (
<PublicLoadError href={`/guilds/${guild.id}/forum/${thread.id}`} />
)}
{postsFailed ? (
<PublicLoadError href={`/guilds/${guild.id}/forum/${thread.id}`} />
) : posts.length === 0 ? (
<EmptyState icon="📝">{t("postsEmpty")}</EmptyState>
) : (
<ul
+32 -8
View File
@@ -3,9 +3,11 @@ import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import Link from "@/components/link";
import { PublicLoadError } from "@/components/public/load-error";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, Guilds, GuildsForumsThreads, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { publicReadFailure } from "@/lib/services/public-read";
type SearchParams = Promise<{ posted?: string }>;
@@ -49,7 +51,7 @@ export default async function GuildForumPage({
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
// Missing guilds and unavailable guild data have distinct outcomes.
let guild: { id: number; name: string; badge: string } | null = null;
try {
const [row] = await db
@@ -62,13 +64,22 @@ export default async function GuildForumPage({
.where(eq(Guilds.id, guildId))
.limit(1);
guild = row ?? null;
} catch {
guild = null;
} catch (error) {
publicReadFailure("guild.forum")(error);
return (
<main className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum`} />
</ContentCard>
</main>
);
}
if (!guild) notFound();
// Threads for this guild. Pinned first, then most-recently active.
let threadsFailed = false;
let authorsFailed = false;
let threads: ThreadRow[] = [];
try {
threads = await db
@@ -95,7 +106,9 @@ export default async function GuildForumPage({
desc(GuildsForumsThreads.id),
)
.limit(100);
} catch {
} catch (error) {
publicReadFailure("guild.threads")(error);
threadsFailed = true;
threads = [];
}
@@ -113,7 +126,9 @@ export default async function GuildForumPage({
.from(User)
.where(inArray(User.id, openerIds))
: [];
} catch {
} catch (error) {
publicReadFailure("guild.thread-authors")(error);
authorsFailed = true;
users = [];
}
@@ -134,7 +149,11 @@ export default async function GuildForumPage({
<ContentCard
icon="🗣️"
title={t("forumTitle", { guild: guild.name || t("unnamedGuild") })}
subtitle={`${threads.length} ${threads.length === 1 ? t("threadSingular") : t("threadPlural")}`}
subtitle={
threadsFailed
? undefined
: `${threads.length} ${threads.length === 1 ? t("threadSingular") : t("threadPlural")}`
}
action={
<Link
href={`/guilds/${guild.id}/forum/new`}
@@ -145,8 +164,13 @@ export default async function GuildForumPage({
}
/>
<ContentCard padded={threads.length === 0}>
{threads.length === 0 ? (
<ContentCard padded={threads.length === 0 || authorsFailed}>
{authorsFailed && (
<PublicLoadError href={`/guilds/${guild.id}/forum`} />
)}
{threadsFailed ? (
<PublicLoadError href={`/guilds/${guild.id}/forum`} />
) : threads.length === 0 ? (
<EmptyState icon="🗣️">{t("empty")}</EmptyState>
) : (
<table>
+29 -8
View File
@@ -2,10 +2,12 @@ import { asc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { PublicLoadError } from "@/components/public/load-error";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { db, Guilds, GuildsMembers, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { publicReadFailure } from "@/lib/services/public-read";
type GuildView = {
id: number;
@@ -50,14 +52,22 @@ export default async function GuildPage({
.where(eq(Guilds.id, guildId))
.limit(1);
guild = row ?? null;
} catch {
guild = null;
} catch (error) {
publicReadFailure("guild.detail")(error);
return (
<main className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}`} />
</ContentCard>
</main>
);
}
if (!guild) notFound();
// Members come from guilds_members; there is no relation in the schema, so we
// join the users table manually by id.
let membersFailed = false;
let memberRows: { userId: number; memberSince: number }[] = [];
try {
memberRows = await db
@@ -69,7 +79,9 @@ export default async function GuildPage({
.where(eq(GuildsMembers.guildId, guild.id))
.orderBy(asc(GuildsMembers.memberSince))
.limit(200);
} catch {
} catch (error) {
publicReadFailure("guild.members")(error);
membersFailed = true;
memberRows = [];
}
@@ -93,7 +105,9 @@ export default async function GuildPage({
.from(User)
.where(inArray(User.id, memberIds))
: [];
} catch {
} catch (error) {
publicReadFailure("guild.member-users")(error);
membersFailed = true;
users = [];
}
@@ -159,7 +173,7 @@ export default async function GuildPage({
<div className="stat-grid" style={{ marginTop: "1.1rem" }}>
<StatBlock
icon="👥"
value={members.length}
value={membersFailed ? "—" : members.length}
label={
members.length === 1 ? t("memberSingular") : t("memberPlural")
}
@@ -185,11 +199,18 @@ export default async function GuildPage({
<ContentCard
icon="👥"
title={t("membersTitle")}
subtitle={`${members.length} ${members.length === 1 ? t("memberLowerSingular") : t("memberLowerPlural")}`}
padded={members.length === 0}
subtitle={
membersFailed
? undefined
: `${members.length} ${members.length === 1 ? t("memberLowerSingular") : t("memberLowerPlural")}`
}
padded={members.length === 0 || membersFailed}
>
{membersFailed && <PublicLoadError href={`/guilds/${guild.id}`} />}
{members.length === 0 ? (
<EmptyState icon="👥">{t("noMembers")}</EmptyState>
membersFailed ? null : (
<EmptyState icon="👥">{t("noMembers")}</EmptyState>
)
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{members.map((m) => (
+9 -5
View File
@@ -2,9 +2,11 @@ import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { PublicLoadError } from "@/components/public/load-error";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, Guilds } from "@/lib/db";
import { excerpt } from "@/lib/format";
import { publicReadFailure } from "@/lib/services/public-read";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.guilds");
@@ -26,7 +28,7 @@ type GuildCard = {
badge: string;
};
async function getGuilds(): Promise<GuildCard[]> {
async function getGuilds(): Promise<GuildCard[] | null> {
try {
return await db
.select({
@@ -38,8 +40,8 @@ async function getGuilds(): Promise<GuildCard[]> {
.from(Guilds)
.orderBy(desc(Guilds.id))
.limit(40);
} catch {
return [];
} catch (error) {
return publicReadFailure("guilds")(error);
}
}
@@ -51,8 +53,10 @@ export default async function GuildsPage() {
<main className="page-grid">
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={guilds.length === 0}>
{guilds.length === 0 ? (
<ContentCard padded={!guilds?.length}>
{guilds === null ? (
<PublicLoadError href="/guilds" />
) : guilds.length === 0 ? (
<EmptyState icon="🚪">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
+22 -5
View File
@@ -267,7 +267,11 @@ export default async function MePage({
actionLabel={t("allFriends")}
bodyClassName="p-5"
>
{onlineFriends.length === 0 ? (
{data.friendsUnavailable ? (
<p role="status" className="muted">
{t("loadError")}
</p>
) : onlineFriends.length === 0 ? (
<p className="muted">{t("noOnlineFriends")}</p>
) : (
<div className={styles.friends}>
@@ -324,13 +328,22 @@ export default async function MePage({
{[
{
label: t("achievements"),
value: format.number(userSettings?.achievementScore ?? 0),
value: data.userSettingsUnavailable
? "—"
: format.number(userSettings?.achievementScore ?? 0),
},
{
label: t("respects"),
value: format.number(userSettings?.respectsReceived ?? 0),
value: data.userSettingsUnavailable
? "—"
: format.number(userSettings?.respectsReceived ?? 0),
},
{
label: t("friends"),
value: data.friendsUnavailable
? "—"
: format.number(friendCount),
},
{ label: t("friends"), value: format.number(friendCount) },
{ label: t("lastLogin"), value: date(user.lastLogin) },
].map((stat) => (
<div key={stat.label}>
@@ -400,7 +413,11 @@ export default async function MePage({
</details>
</SurfaceCard>
<SurfaceCard title={t("badges")} bodyClassName="p-5">
{badges.length === 0 ? (
{data.badgesUnavailable ? (
<p role="status" className="muted">
{t("loadError")}
</p>
) : badges.length === 0 ? (
<p className="muted">{t("noBadges")}</p>
) : (
<div className={styles.badges}>
+94 -69
View File
@@ -4,6 +4,8 @@ import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { PublicLoadError } from "@/components/public/load-error";
import { publicReadFailure } from "@/lib/services/public-read";
export const metadata: Metadata = {
title: "Home",
@@ -53,34 +55,36 @@ async function getHotelData() {
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
).catch(publicReadFailure("home.online")),
cached("total_users", 300_000, () =>
db
.select({ total: count() })
.from(User)
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
).catch(publicReadFailure("home.users")),
cached("total_rooms", 300_000, () =>
db
.select({ total: count() })
.from(Rooms)
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
getNewsList(4),
).catch(publicReadFailure("home.rooms")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
cached("home_online_users", 15_000, () =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(12),
).catch(() => []),
).catch(publicReadFailure("home.recent-users")),
cached("home_recent_photos", 60_000, () =>
db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(4),
).catch(() => []),
).catch(publicReadFailure("home.photos")),
]);
return {
@@ -100,6 +104,7 @@ export default async function Home() {
if (session?.user?.id) redirect("/me");
const tp = await getTranslations("pages.home");
const te = await getTranslations("publicData");
const th = await getTranslations("header");
const {
hotelName,
@@ -251,7 +256,11 @@ export default async function Home() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
<LiveOnlineCount initial={online} hotelName={hotelName} />
{online === null ? (
<span>{te("unavailable")}</span>
) : (
<LiveOnlineCount initial={online} hotelName={hotelName} />
)}
</div>
<h1
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
@@ -368,7 +377,7 @@ export default async function Home() {
live: true,
},
{
value: articles.length,
value: articles?.length ?? null,
label: tp("statsArticles"),
icon: "/assets/images/icons/article.gif",
live: false,
@@ -379,7 +388,9 @@ export default async function Home() {
className="relative p-3 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
{s.live ? (
{s.value === null ? (
<span className="text-base">{te("unavailable")}</span>
) : s.live ? (
<LiveOnlineCounter initial={s.value} />
) : (
<AnimatedCounter value={s.value} />
@@ -415,36 +426,42 @@ export default async function Home() {
/>
</SurfaceCard>
{recentUsers.length > 0 && (
<SurfaceCard
title={tp("recentUsers")}
icon="/assets/images/icons/navigation/goody.png"
actionHref="/community"
actionLabel={tp("allUsers")}
bodyClassName="p-3.5 sm:p-4"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[color-mix(in_srgb,var(--color-primary)_5%,transparent)]"
>
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-md"
/>
<span
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
{recentUsers === null ? (
<SurfaceCard title={tp("recentUsers")} bodyClassName="p-4">
<PublicLoadError href="/" />
</SurfaceCard>
) : (
recentUsers.length > 0 && (
<SurfaceCard
title={tp("recentUsers")}
icon="/assets/images/icons/navigation/goody.png"
actionHref="/community"
actionLabel={tp("allUsers")}
bodyClassName="p-3.5 sm:p-4"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[color-mix(in_srgb,var(--color-primary)_5%,transparent)]"
>
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-md"
/>
<span
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</SurfaceCard>
)
)}
</Reveal>
@@ -456,7 +473,9 @@ export default async function Home() {
actionHref="/news"
actionLabel={tp("allNews")}
>
{articles.length > 0 ? (
{articles === null ? (
<PublicLoadError href="/" />
) : articles.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
{articles.slice(0, 4).map((a) => (
<Link
@@ -517,36 +536,42 @@ export default async function Home() {
</div>
{/* ── Recent photos ── */}
{recentPhotos.length > 0 && (
<Reveal>
<SurfaceCard
title={tp("recentPhotos")}
icon="/assets/images/icons/camera.png"
>
<div className="mx-auto grid max-w-md grid-cols-4 gap-2">
{recentPhotos.slice(0, 4).map((p) => (
<Link
key={p.id}
href="/photos"
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md hover:scale-[1.02]"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
}}
>
<Image
src={p.url}
alt=""
width={200}
height={200}
className="h-full w-full object-cover"
unoptimized
/>
</Link>
))}
</div>
</SurfaceCard>
</Reveal>
{recentPhotos === null ? (
<SurfaceCard title={tp("recentPhotos")} bodyClassName="p-4">
<PublicLoadError href="/" />
</SurfaceCard>
) : (
recentPhotos.length > 0 && (
<Reveal>
<SurfaceCard
title={tp("recentPhotos")}
icon="/assets/images/icons/camera.png"
>
<div className="mx-auto grid max-w-md grid-cols-4 gap-2">
{recentPhotos.slice(0, 4).map((p) => (
<Link
key={p.id}
href="/photos"
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md hover:scale-[1.02]"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
}}
>
<Image
src={p.url}
alt=""
width={200}
height={200}
className="h-full w-full object-cover"
unoptimized
/>
</Link>
))}
</div>
</SurfaceCard>
</Reveal>
)
)}
</div>
);
+9 -5
View File
@@ -1,6 +1,7 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { PublicLoadError } from "@/components/public/load-error";
import {
type LightboxPhoto,
PhotoLightbox,
@@ -9,6 +10,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
import { cached } from "@/lib/cache";
import { CameraWeb, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { publicReadFailure } from "@/lib/services/public-read";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.photos");
@@ -34,18 +36,18 @@ type Photo = {
export default async function PhotosPage() {
const t = await getTranslations("pages.photos");
let photos: Photo[] = [];
let photos: Photo[] | null = [];
try {
photos = await cached("photos:grid", 60_000, async () =>
db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48),
);
} catch {
photos = [];
} catch (error) {
photos = publicReadFailure("photos")(error);
}
// Pre-shape for the client lightbox: translate captions server-side so the
// client component stays free of i18n/db dependencies.
const items: LightboxPhoto[] = photos.map((p) => ({
const items: LightboxPhoto[] = (photos ?? []).map((p) => ({
id: String(p.id),
url: p.url,
alt: t("photoAlt", { id: p.userId }),
@@ -58,7 +60,9 @@ export default async function PhotosPage() {
<ContentCard icon="📸" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={items.length === 0}>
{items.length === 0 ? (
{photos === null ? (
<PublicLoadError href="/photos" />
) : items.length === 0 ? (
<EmptyState icon="📸">{t("noPhotos")}</EmptyState>
) : (
<PhotoLightbox photos={items} />
+9 -5
View File
@@ -2,6 +2,7 @@ import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { PublicLoadError } from "@/components/public/load-error";
import {
ContentCard,
EmptyState,
@@ -12,6 +13,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { publicReadFailure } from "@/lib/services/public-read";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.rankings");
@@ -36,7 +38,7 @@ type TopUser = {
export default async function RankingsPage() {
const t = await getTranslations("pages.rankings");
let users: TopUser[] = [];
let users: TopUser[] | null = [];
try {
users = await cached("rankings:top", 60_000, async () =>
db
@@ -51,16 +53,18 @@ export default async function RankingsPage() {
.orderBy(desc(User.credits))
.limit(12),
);
} catch {
users = [];
} catch (error) {
users = publicReadFailure("rankings")(error);
}
return (
<main className="page-grid">
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={users.length === 0}>
{users.length === 0 ? (
<ContentCard padded={!users?.length}>
{users === null ? (
<PublicLoadError href="/rankings" />
) : users.length === 0 ? (
<EmptyState icon="🏆">{t("emptyState")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
+36 -43
View File
@@ -2,10 +2,12 @@ import { asc, desc, eq, gte } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { PublicLoadError } from "@/components/public/load-error";
import { ProfileImage } from "@/components/shared/profile-image";
import { SurfaceCard } from "@/components/surface-card";
import { db, User, WebsiteTeams } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format";
import { publicReadFailure } from "@/lib/services/public-read";
import { siteSettings } from "@/lib/services/site-settings";
import styles from "./staff.module.css";
@@ -22,21 +24,6 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
type StaffMember = {
username: string;
look: string;
rank: number;
motto: string;
};
type Team = {
id: string;
rankName: string;
badge: string | null;
jobDescription: string | null;
staffColor: string;
};
export default async function StaffPage() {
const t = await getTranslations("pages.staff");
const minStaffRank =
@@ -55,7 +42,7 @@ export default async function StaffPage() {
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username))
.limit(100)
.catch((): StaffMember[] | null => null),
.catch(publicReadFailure("staff.members")),
db
.select({
id: WebsiteTeams.id,
@@ -68,7 +55,7 @@ export default async function StaffPage() {
.where(eq(WebsiteTeams.hiddenRank, false))
.orderBy(asc(WebsiteTeams.id))
.then((rows) => rows.map((row) => ({ ...row, id: String(row.id) })))
.catch((): Team[] => []),
.catch(publicReadFailure("staff.teams")),
]);
return (
@@ -131,32 +118,38 @@ export default async function StaffPage() {
</ul>
)}
</section>
{teams.length > 0 && (
<section aria-labelledby="staff-teams">
<h2 id="staff-teams" className={styles.heading}>
{t("ranksTitle")}
</h2>
<p className={styles.subtitle}>{t("ranksSubtitle")}</p>
<ul className={styles.grid}>
{teams.map((team) => (
<li key={team.id}>
<SurfaceCard className={styles.team}>
<span
className={styles.accent}
style={{
backgroundColor: /^#[0-9a-f]{6}$/i.test(team.staffColor)
? team.staffColor
: "var(--color-primary)",
}}
aria-hidden="true"
/>
<h3>{team.rankName}</h3>
{team.jobDescription && <p>{team.jobDescription}</p>}
</SurfaceCard>
</li>
))}
</ul>
</section>
{teams === null ? (
<SurfaceCard className={styles.notice}>
<PublicLoadError href="/staff" />
</SurfaceCard>
) : (
teams.length > 0 && (
<section aria-labelledby="staff-teams">
<h2 id="staff-teams" className={styles.heading}>
{t("ranksTitle")}
</h2>
<p className={styles.subtitle}>{t("ranksSubtitle")}</p>
<ul className={styles.grid}>
{teams.map((team) => (
<li key={team.id}>
<SurfaceCard className={styles.team}>
<span
className={styles.accent}
style={{
backgroundColor: /^#[0-9a-f]{6}$/i.test(team.staffColor)
? team.staffColor
: "var(--color-primary)",
}}
aria-hidden="true"
/>
<h3>{team.rankName}</h3>
{team.jobDescription && <p>{team.jobDescription}</p>}
</SurfaceCard>
</li>
))}
</ul>
</section>
)
)}
</main>
);