feat(staff): present members in responsive profile cards

This commit is contained in:
Simo committed 2026-09-06 11:53:05 +02:00
1 parent 7c1f109a9d
commit b5dcadb67b
4 files changed
+250 -70

No files matched your search

+90 -66
View File
@@ -2,9 +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 { ContentCard, EmptyState } from "@/components/public/ui";
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 { siteSettings } from "@/lib/services/site-settings";
import styles from "./staff.module.css";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.staff");
@@ -52,7 +55,7 @@ export default async function StaffPage() {
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username))
.limit(100)
.catch((): StaffMember[] => []),
.catch((): StaffMember[] | null => null),
db
.select({
id: WebsiteTeams.id,
@@ -69,71 +72,92 @@ export default async function StaffPage() {
]);
return (
<main className="page-grid">
<ContentCard icon="🛡️" title={t("title")} subtitle={t("subtitle")} />
{teams.length > 0 ? (
<ContentCard
icon="⭐"
title={t("ranksTitle")}
subtitle={t("ranksSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{teams.map((team) => (
<div key={team.id} className="card">
<h3 style={{ margin: "0 0 0.25rem", color: team.staffColor }}>
{team.rankName}
</h3>
{team.jobDescription ? (
<p className="muted" style={{ margin: 0 }}>
{team.jobDescription}
</p>
) : null}
</div>
))}
</div>
</ContentCard>
) : null}
<ContentCard
icon="👥"
title={t("membersTitle")}
subtitle={
staff.length === 0
? t("membersEmptySubtitle")
: t("membersSubtitle", { count: staff.length })
}
padded={staff.length === 0}
>
{staff.length === 0 ? (
<EmptyState icon="🛡️">{t("emptyState")}</EmptyState>
) : (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>{t("colUsername")}</th>
<th>{t("colRank")}</th>
<th>{t("colMotto")}</th>
</tr>
</thead>
<tbody>
{staff.map((u) => (
<tr key={u.username}>
<td>
<Link href={`/u/${u.username}`}>{u.username}</Link>
</td>
<td>{u.rank}</td>
<td className="muted table-cell-truncate">
{u.motto || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<main className={styles.page}>
<SurfaceCard className={styles.hero}>
<div>
<h1>{t("title")}</h1>
<p>{t("subtitle")}</p>
</div>
{staff && staff.length > 0 && (
<span className={styles.count}>
{t("membersSubtitle", { count: staff.length })}
</span>
)}
</ContentCard>
</SurfaceCard>
<section aria-labelledby="staff-members">
<h2 id="staff-members" className={styles.heading}>
{t("membersTitle")}
</h2>
{staff === null ? (
<SurfaceCard className={styles.notice}>
<p role="alert">{t("loadError")}</p>
<Link href="/staff" className="btn btn-outline">
{t("retry")}
</Link>
</SurfaceCard>
) : staff.length === 0 ? (
<SurfaceCard className={styles.notice}>
<p>{t("emptyState")}</p>
</SurfaceCard>
) : (
<ul className={styles.grid}>
{staff.map((member) => (
<li key={member.username}>
<Link
href={`/u/${encodeURIComponent(member.username)}`}
className={styles.member}
>
<ProfileImage
src={avatarImageUrl(member.look, {
size: "m",
direction: 2,
headDirection: 3,
})}
alt={member.username}
width={64}
height={110}
className={styles.avatar}
/>
<div className={styles.identity}>
<h3>{member.username}</h3>
<p>{member.motto || t("noMotto")}</p>
<span className={styles.profile}>
{t("viewProfile")} <span aria-hidden="true">→</span>
</span>
</div>
</Link>
</li>
))}
</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>
)}
</main>
);
}
+148
View File
@@ -0,0 +1,148 @@
.page {
display: grid;
gap: 28px;
min-width: 0;
padding-bottom: 24px;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 20px;
padding: 28px;
background: linear-gradient(
130deg,
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
var(--color-surface)
);
}
.hero h1 {
margin: 0 0 6px;
font-size: clamp(1.6rem, 3vw, 2rem);
}
.hero p,
.subtitle {
color: var(--color-text-muted);
margin: 0;
}
.count {
font-size: 0.85rem;
padding: 10px 14px;
border-radius: 12px;
background: var(--color-surface);
color: var(--color-text-readable);
}
.heading {
font-size: 1.15rem;
margin: 0 0 14px;
}
.subtitle {
margin: -6px 0 18px;
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
list-style: none;
margin: 0;
padding: 0;
}
.grid li {
min-width: 0;
}
.member {
display: flex;
align-items: center;
gap: 18px;
height: 100%;
padding: 20px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
color: var(--color-text-readable);
text-decoration: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.member:hover {
border-color: var(--color-primary);
box-shadow: 0 4px 16px
color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.member:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 4px;
}
.avatar {
flex-shrink: 0;
image-rendering: pixelated;
object-fit: contain;
border-radius: 12px;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.identity {
min-width: 0;
overflow-wrap: anywhere;
}
.identity h3,
.team h3 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.identity p,
.team p {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
white-space: pre-line;
overflow-wrap: anywhere;
}
.profile {
display: inline-block;
margin-top: 16px;
font-size: 0.8rem;
font-weight: 600;
}
.team {
position: relative;
height: 100%;
padding: 22px;
overflow-wrap: anywhere;
}
.accent {
display: block;
width: 32px;
height: 4px;
border-radius: 4px;
margin-bottom: 16px;
}
.notice {
padding: 24px;
}
.notice p {
margin: 0 0 12px;
}
@media (max-width: 1000px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
.hero {
padding: 22px;
}
.member {
padding: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.member {
transition: none;
}
}
+6 -2
View File
@@ -67,7 +67,7 @@
"staff": {
"title": "Staff",
"subtitle": "The team behind the hotel",
"ranksTitle": "Ranks",
"ranksTitle": "Our teams",
"ranksSubtitle": "What each part of the team takes care of",
"membersTitle": "Team members",
"membersEmptySubtitle": "Nobody to show right now",
@@ -75,7 +75,11 @@
"emptyState": "No staff members to show.",
"colUsername": "Username",
"colRank": "Rank",
"colMotto": "Motto"
"colMotto": "Motto",
"viewProfile": "View profile",
"noMotto": "No motto set",
"loadError": "Unable to load staff members. Please try again shortly.",
"retry": "Try again"
},
"leaderboard": {
"title": "Leaderboard",
+6 -2
View File
@@ -67,7 +67,7 @@
"staff": {
"title": "Staff",
"subtitle": "Il team dietro l'hotel",
"ranksTitle": "Ruoli",
"ranksTitle": "I nostri team",
"ranksSubtitle": "Di cosa si occupa ogni parte del team",
"membersTitle": "Membri del team",
"membersEmptySubtitle": "Nessuno da mostrare al momento",
@@ -75,7 +75,11 @@
"emptyState": "Nessun membro dello staff da mostrare.",
"colUsername": "Nome utente",
"colRank": "Ruolo",
"colMotto": "Motto"
"colMotto": "Motto",
"viewProfile": "Visita il profilo",
"noMotto": "Nessun motto impostato",
"loadError": "Impossibile caricare i membri dello staff. Riprova tra poco.",
"retry": "Riprova"
},
"leaderboard": {
"title": "Classifica",