feat(staff): present members in responsive profile cards
This commit is contained in:
1 parent
7c1f109a9d
commit
b5dcadb67b
4 files changed
+249
-69
No files matched your search
@@ -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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user