feat(staff): present members in responsive profile cards
This commit is contained in:
1 parent
7c1f109a9d
commit
b5dcadb67b
4 files changed
+250
-70
No files matched your search
@@ -2,9 +2,12 @@ import { asc, desc, eq, gte } from "drizzle-orm";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
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 { db, User, WebsiteTeams } from "@/lib/db";
|
||||||
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import styles from "./staff.module.css";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations("pages.staff");
|
const t = await getTranslations("pages.staff");
|
||||||
@@ -52,7 +55,7 @@ export default async function StaffPage() {
|
|||||||
.where(gte(User.rank, minStaffRank))
|
.where(gte(User.rank, minStaffRank))
|
||||||
.orderBy(desc(User.rank), asc(User.username))
|
.orderBy(desc(User.rank), asc(User.username))
|
||||||
.limit(100)
|
.limit(100)
|
||||||
.catch((): StaffMember[] => []),
|
.catch((): StaffMember[] | null => null),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: WebsiteTeams.id,
|
id: WebsiteTeams.id,
|
||||||
@@ -69,71 +72,92 @@ export default async function StaffPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-grid">
|
<main className={styles.page}>
|
||||||
<ContentCard icon="🛡️" title={t("title")} subtitle={t("subtitle")} />
|
<SurfaceCard className={styles.hero}>
|
||||||
|
<div>
|
||||||
{teams.length > 0 ? (
|
<h1>{t("title")}</h1>
|
||||||
<ContentCard
|
<p>{t("subtitle")}</p>
|
||||||
icon="⭐"
|
</div>
|
||||||
title={t("ranksTitle")}
|
{staff && staff.length > 0 && (
|
||||||
subtitle={t("ranksSubtitle")}
|
<span className={styles.count}>
|
||||||
>
|
{t("membersSubtitle", { count: staff.length })}
|
||||||
<div className="card-grid sm-2 lg-3">
|
</span>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</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": {
|
"staff": {
|
||||||
"title": "Staff",
|
"title": "Staff",
|
||||||
"subtitle": "The team behind the hotel",
|
"subtitle": "The team behind the hotel",
|
||||||
"ranksTitle": "Ranks",
|
"ranksTitle": "Our teams",
|
||||||
"ranksSubtitle": "What each part of the team takes care of",
|
"ranksSubtitle": "What each part of the team takes care of",
|
||||||
"membersTitle": "Team members",
|
"membersTitle": "Team members",
|
||||||
"membersEmptySubtitle": "Nobody to show right now",
|
"membersEmptySubtitle": "Nobody to show right now",
|
||||||
@@ -75,7 +75,11 @@
|
|||||||
"emptyState": "No staff members to show.",
|
"emptyState": "No staff members to show.",
|
||||||
"colUsername": "Username",
|
"colUsername": "Username",
|
||||||
"colRank": "Rank",
|
"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": {
|
"leaderboard": {
|
||||||
"title": "Leaderboard",
|
"title": "Leaderboard",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"staff": {
|
"staff": {
|
||||||
"title": "Staff",
|
"title": "Staff",
|
||||||
"subtitle": "Il team dietro l'hotel",
|
"subtitle": "Il team dietro l'hotel",
|
||||||
"ranksTitle": "Ruoli",
|
"ranksTitle": "I nostri team",
|
||||||
"ranksSubtitle": "Di cosa si occupa ogni parte del team",
|
"ranksSubtitle": "Di cosa si occupa ogni parte del team",
|
||||||
"membersTitle": "Membri del team",
|
"membersTitle": "Membri del team",
|
||||||
"membersEmptySubtitle": "Nessuno da mostrare al momento",
|
"membersEmptySubtitle": "Nessuno da mostrare al momento",
|
||||||
@@ -75,7 +75,11 @@
|
|||||||
"emptyState": "Nessun membro dello staff da mostrare.",
|
"emptyState": "Nessun membro dello staff da mostrare.",
|
||||||
"colUsername": "Nome utente",
|
"colUsername": "Nome utente",
|
||||||
"colRank": "Ruolo",
|
"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": {
|
"leaderboard": {
|
||||||
"title": "Classifica",
|
"title": "Classifica",
|
||||||
|
|||||||
Reference in new issue
Block a user