Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s
Local Build and Deploy / deploy (push) Successful in 54s
Public chrome and width live only under (site). /admin and /client inherit the root layout alone, so housekeeping width is no longer tied to the public template. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
a1dc7ff0a7
commit
367f69a439
60 files changed
+29
-30
No files matched your search
@@ -0,0 +1,163 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { applyStaff } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
export default async function ApplyStaffPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.applyStaff");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
|
||||
function formatDeadline(d: Date | null | undefined): string {
|
||||
if (!d) return t("noDeadline");
|
||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
// ── Open positions ──────────────────────────────────────────
|
||||
// Each query is isolated so a DB hiccup degrades that section to empty
|
||||
// rather than 500-ing the whole page.
|
||||
const positions = await prisma.websiteOpenPositions
|
||||
.findMany({ orderBy: { createdAt: "desc" } })
|
||||
.catch(() => []);
|
||||
|
||||
// Resolve display info for each position's rank. The rich rank fields
|
||||
// (rankName/badge/jobDescription/staffColor) live in website_teams, keyed by
|
||||
// the same rank id the position's permission_id points at.
|
||||
const rankIds = Array.from(new Set(positions.map((p) => p.permissionId)));
|
||||
const teams = rankIds.length
|
||||
? await prisma.websiteTeams
|
||||
.findMany({
|
||||
where: { id: { in: rankIds.map((r) => BigInt(r)) } },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const teamByRankId = new Map(teams.map((t) => [Number(t.id), t]));
|
||||
|
||||
// Ranks this user has already applied for (block re-applying).
|
||||
const myApps = await prisma.websiteStaffApplications
|
||||
.findMany({ where: { userId }, select: { rankId: true } })
|
||||
.catch(() => []);
|
||||
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🛡️"
|
||||
title={t("title", { hotel: hotelName })}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
|
||||
{positions.length === 0 ? (
|
||||
<ContentCard icon="🛡️" title={t("noPositionsTitle")}>
|
||||
<EmptyState icon="🛡️">{t("noPositionsBody")}</EmptyState>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<div className="card-grid sm-2">
|
||||
{positions.map((position) => {
|
||||
const team = teamByRankId.get(position.permissionId);
|
||||
const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
|
||||
const alreadyApplied = appliedRankIds.has(position.permissionId);
|
||||
return (
|
||||
<article key={String(position.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
alt={rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
🛡️
|
||||
</span>
|
||||
)}
|
||||
<div className="content-card-head-text">
|
||||
<p
|
||||
className="content-card-title"
|
||||
style={{ color: team?.staffColor || undefined }}
|
||||
>
|
||||
{rankName}
|
||||
</p>
|
||||
{team?.jobDescription ? (
|
||||
<p className="content-card-subtitle">
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card-body">
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{t("deadline", { date: formatDeadline(position.applyTo) })}
|
||||
</p>
|
||||
|
||||
{alreadyApplied ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("alreadyApplied", { rank: rankName })}
|
||||
</button>
|
||||
) : (
|
||||
<form action={applyStaff}>
|
||||
{/* rank_id is the position's permission id; the applicant is
|
||||
re-read from the session inside the action. */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="rankId"
|
||||
value={String(position.permissionId)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`content-${position.id}`}
|
||||
className="muted"
|
||||
>
|
||||
{t("aboutYou")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`content-${position.id}`}
|
||||
name="content"
|
||||
required
|
||||
minLength={10}
|
||||
rows={5}
|
||||
placeholder={t("aboutPlaceholder", { rank: rankName })}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("applyFor", { rank: rankName })}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { applyTeam } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
export default async function ApplyTeamPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.applyTeam");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
|
||||
// ── Teams open for application ──────────────────────────────
|
||||
// The Prisma slice exposes teams directly (no separate team-position table
|
||||
// with position_kind), so the team application concept lists website_teams.
|
||||
// Hidden ranks are excluded from the public apply page.
|
||||
const teams = await prisma.websiteTeams
|
||||
.findMany({
|
||||
where: { hiddenRank: false },
|
||||
orderBy: { rankName: "asc" },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Teams this user has already applied to. Team applications reuse the staff
|
||||
// applications table with rank_id carrying the team id (the team flag).
|
||||
const myApps = await prisma.websiteStaffApplications
|
||||
.findMany({ where: { userId }, select: { rankId: true } })
|
||||
.catch(() => []);
|
||||
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title={t("title", { hotel: hotelName })}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<ContentCard icon="🤝" title={t("noPositionsTitle")}>
|
||||
<EmptyState icon="🤝">{t("noPositionsBody")}</EmptyState>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<div className="card-grid sm-2">
|
||||
{teams.map((team) => {
|
||||
const teamId = Number(team.id);
|
||||
const alreadyApplied = appliedTeamIds.has(teamId);
|
||||
return (
|
||||
<article key={String(team.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
alt={team.rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
) : (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
🤝
|
||||
</span>
|
||||
)}
|
||||
<div className="content-card-head-text">
|
||||
<p
|
||||
className="content-card-title"
|
||||
style={{ color: team.staffColor || undefined }}
|
||||
>
|
||||
{team.rankName}
|
||||
</p>
|
||||
{team.jobDescription ? (
|
||||
<p className="content-card-subtitle">
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card-body">
|
||||
{alreadyApplied ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("pending")}
|
||||
</button>
|
||||
) : (
|
||||
<form action={applyTeam}>
|
||||
{/* The team id is the application's rank flag; the applicant
|
||||
is re-read from the session inside the action. */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="teamId"
|
||||
value={String(team.id)}
|
||||
/>
|
||||
<label htmlFor={`content-${team.id}`} className="muted">
|
||||
{t("aboutYou")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`content-${team.id}`}
|
||||
name="content"
|
||||
required
|
||||
minLength={10}
|
||||
rows={5}
|
||||
placeholder={t("aboutPlaceholder", {
|
||||
rank: team.rankName,
|
||||
})}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("applyFor", { rank: team.rankName })}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user