Skip to content

Commit f0d4290

Browse files
committed
ui: end-to-end UI/UX revamp (terminal HUD theme)
Foundation - New design tokens (semantic surfaces, ink, brand, signal, borders, glows) - Font stack: JetBrains Mono (display/mono) + Inter (body) + Space Grotesk (alt heading) - New tailwind config: brand/surface/ink/signal palette, shadow + animation system - card-surface utility (translucent dark, lets page grid show through) - Reusable HudFrame component (corner brackets + top accent line) Atomic components rebuilt - button, card, modal, form-input/textarea/select/phone/file-upload, custom-dropdown - status-badge, alert-banner, sticky-alert (inline, not fixed-overlay) - section-tab, confirmation-dialog, deadline-timer - form-section (hero card with HUD) Shells - navbar with persistent nav, role-aware items, isAuthLoading skeleton, avatar links to profile for participants only - dot-pattern: terminal grid + phosphor + halo backdrop - dashboard layout with role-based route policy (RBAC enforced at layout level) - shared dashboard loading skeleton Containers - landing, dashboard (StatusStrip + Inbox merging invites/requests), profile (looking-for-team broadcast panel, dossier/links/files tabs), team (Transfer Lead, roster, invite flow), discover (command-bar search, J/K-style nav unnecessary here, role-aware view), admin (Command Center + Bloomberg-style analytics deck), evaluator (triage queue with J/K/Enter keyboard navigation + per-team warm-up flag indicator), registration (5-step wizard with progress rail and scroll-gated CoC modal) Auth + top-level - login, register, forgot-password rebuilt - shortlisted page rebuilt - new app/dashboard/resume page wraps PDF viewer in navbar so resume opens with app chrome UX fixes - Promise.all parallelization in dashboard / discover / admin fetches - Stale-state guard after team delete (discover lockout) - Accept/Decline buttons show pending state + grid alignment - Profile shows current resume + photo (view links) - Bio compulsory, Discord regex validation, file size client check - Profile completeness meter surfaced in dashboard StatusStrip - Em-dashes swept, inline divider lines removed - Looking for team copy clarified (solo is fine, cap at 2) - Noob badge softened to warm-up flag terminology - v5.0 chip removed (was redundant with logo) - Single-load skeleton (no more three different loading screens) - FOUC prevention via inline html/body bg styles Misc - TEAM_SIZE / DISCORD_USERNAME_REGEX / FILE_SIZE constants in lib/constants.ts - evaluators-tab uses ConfirmationDialog (replaces window.confirm) - Spinner, help-button, alert-dialog aligned with new tokens - netlify.toml for Next.js plugin deploy
1 parent 1cb648b commit f0d4290

61 files changed

Lines changed: 9054 additions & 5913 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ yarn-error.log*
3333
# typescript
3434
*.tsbuildinfo
3535
next-env.d.ts
36+
37+
# Local Netlify folder
38+
.netlify
39+
40+
# Netlify edge function build artifacts
41+
deno.lock

app/api/evaluator/teams/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
7474
}
7575

7676
} else {
77-
// All Teams view show every team, optionally filtered by evaluation tier
77+
// All Teams view. show every team, optionally filtered by evaluation tier
7878
if (tiers.length > 0) {
7979
pipeline.push({ $match: { 'evaluations.tier': { $in: tiers } } });
8080
}

app/dashboard/layout.tsx

Lines changed: 75 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,45 @@
11
"use client";
22

3-
import { useEffect } from "react";
3+
import { useEffect, useMemo, useState } from "react";
44
import { useRouter, usePathname } from "next/navigation";
55
import { useAuth } from "@/hooks/use-auth";
66
import { NavBar } from "@/components/registration/navbar";
77
import { DotPattern } from "@/components/registration/dot-pattern";
88
import { StickyAlert } from "@/components/registration/sticky-alert";
9-
import { useState } from "react";
10-
import { Spinner } from "@/components/ui/spinner";
9+
10+
// Single source of truth for which roles can access which dashboard segment.
11+
// Longest matching prefix wins. Anything not matched is treated as open to all
12+
// signed-in users (e.g. /dashboard root, which has its own role-based redirect
13+
// below).
14+
type Role = "admin" | "evaluator" | "user" | "frai";
15+
16+
const ROUTE_POLICY: Array<{ prefix: string; roles: Role[] }> = [
17+
// Participant-only routes
18+
{ prefix: "/dashboard/profile", roles: ["user", "frai"] },
19+
{ prefix: "/dashboard/team", roles: ["user", "frai"] },
20+
{ prefix: "/dashboard/discover", roles: ["user", "frai"] },
21+
// Role-scoped workspaces
22+
{ prefix: "/dashboard/admin", roles: ["admin"] },
23+
{ prefix: "/dashboard/evaluator", roles: ["evaluator"] },
24+
{ prefix: "/dashboard/frai", roles: ["frai"] },
25+
// Shared utility pages
26+
{ prefix: "/dashboard/resume", roles: ["user", "frai", "admin", "evaluator"] },
27+
];
28+
29+
const ROLE_LANDING: Record<Role, string> = {
30+
admin: "/dashboard/admin",
31+
evaluator: "/dashboard/evaluator",
32+
frai: "/dashboard/frai",
33+
user: "/dashboard",
34+
};
35+
36+
function policyFor(pathname: string) {
37+
// Pick the longest-matching prefix (so /dashboard/team/x matches /dashboard/team).
38+
const match = ROUTE_POLICY
39+
.filter((p) => pathname === p.prefix || pathname.startsWith(p.prefix + "/"))
40+
.sort((a, b) => b.prefix.length - a.prefix.length)[0];
41+
return match;
42+
}
1143

1244
export default function DashboardLayout({
1345
children,
@@ -22,70 +54,63 @@ export default function DashboardLayout({
2254
message: string;
2355
} | null>(null);
2456
const [hasRefreshed, setHasRefreshed] = useState(false);
57+
2558
useEffect(() => {
2659
if (!isLoading && isAuthenticated && user && !hasRefreshed) {
2760
refreshUser()
28-
.then(() => {
29-
setHasRefreshed(true);
30-
})
61+
.then(() => setHasRefreshed(true))
3162
.catch((error) => {
3263
console.error("Error refreshing user:", error);
3364
setHasRefreshed(true);
3465
});
3566
}
3667
}, [isLoading, isAuthenticated, user, hasRefreshed, refreshUser]);
3768

69+
// Compute whether the current route is forbidden for this user. Used to
70+
// suppress the content render while the redirect below is in flight so we
71+
// don't leak even one frame of the disallowed page (the screenshot bug).
72+
const policy = useMemo(() => policyFor(pathname || ""), [pathname]);
73+
const role = (user?.role as Role | undefined) ?? undefined;
74+
const forbidden =
75+
!!user && !!role && !!policy && !policy.roles.includes(role);
76+
3877
useEffect(() => {
3978
if (isLoading) return;
40-
4179
if (!isAuthenticated || !user) {
4280
router.push("/login");
4381
return;
4482
}
4583

46-
// Redirect based on role only if on base dashboard route
84+
// /dashboard root: redirect privileged roles to their own workspace.
4785
if (pathname === "/dashboard" || pathname === "/dashboard/") {
48-
if (user.role === "admin") {
49-
router.push("/dashboard/admin");
50-
} else if (user.role === "evaluator") {
51-
router.push("/dashboard/evaluator");
52-
} else if (user.role === "frai") {
53-
router.push("/dashboard/frai");
86+
if (role && role !== "user") {
87+
router.replace(ROLE_LANDING[role]);
88+
return;
5489
}
5590
}
56-
}, [isAuthenticated, user, isLoading, router, pathname]);
91+
92+
// Forbidden-by-policy: redirect to that role's landing page.
93+
if (forbidden && role) {
94+
router.replace(ROLE_LANDING[role]);
95+
}
96+
}, [isAuthenticated, user, isLoading, router, pathname, role, forbidden]);
5797

5898
const handleLogout = async () => {
5999
await logout();
60-
// router.push("/login"); // logout() already handles redirect, but keeping this as backup is fine if we await
61-
setAlert({
62-
type: "info",
63-
message: "Logged out successfully",
64-
});
100+
setAlert({ type: "info", message: "Logged out successfully" });
65101
setTimeout(() => setAlert(null), 3000);
66102
};
67103

68-
if (isLoading) {
69-
return (
70-
<div className="min-h-screen w-full flex items-center justify-center bg-[#0a0a0a]">
71-
<Spinner size="lg" />
72-
</div>
73-
);
74-
}
75-
76-
if (!isAuthenticated || !user) {
104+
// Once auth has resolved and confirmed no user, the useEffect above redirects to /login.
105+
// Render nothing in that brief gap to avoid flashing the dashboard shell.
106+
if (!isLoading && (!isAuthenticated || !user)) {
77107
return null;
78108
}
79109

80110
return (
81-
<div
82-
className="min-h-screen w-full flex flex-col items-start relative"
83-
style={{
84-
backgroundImage:
85-
"linear-gradient(90deg, rgb(10,10,10) 0%, rgb(10,10,10) 100%)",
86-
}}
87-
>
111+
<div className="min-h-screen w-full flex flex-col bg-void relative">
88112
<NavBar
113+
isAuthLoading={isLoading}
89114
user={
90115
user && user.name
91116
? {
@@ -98,13 +123,10 @@ export default function DashboardLayout({
98123
}
99124
onLogout={handleLogout}
100125
onNavigate={(view) => {
101-
if (view === "login") {
102-
router.push("/login");
103-
} else if (view === "register") {
104-
router.push("/register");
105-
} else if (view === "landing") {
106-
router.push("/");
107-
} else if (view.startsWith("team?joinCode=")) {
126+
if (view === "login") router.push("/login");
127+
else if (view === "register") router.push("/register");
128+
else if (view === "landing") router.push("/");
129+
else if (view.startsWith("team?joinCode=")) {
108130
const joinCode = view.split("joinCode=")[1];
109131
router.push(`/dashboard/team?joinCode=${joinCode}`);
110132
} else {
@@ -113,15 +135,11 @@ export default function DashboardLayout({
113135
}}
114136
/>
115137

116-
<div className="bg-[#0a0a0a] w-full relative flex-1">
117-
<div
118-
className="flex flex-col items-center justify-center w-full min-h-screen pb-10 pt-10 px-4 md:pb-[80px] md:pt-[60px] md:px-[40px] relative"
119-
style={{
120-
backgroundImage:
121-
"url('data:image/svg+xml;utf8,<svg viewBox=\\'0 0 1440 652\\' xmlns=\\'http://www.w3.org/2000/svg\\' preserveAspectRatio=\\'none\\'><rect x=\\'0\\' y=\\'0\\' height=\\'100%\\' width=\\'100%\\' fill=\\'url(%23grad)\\' opacity=\\'1\\'/><defs><radialGradient id=\\'grad\\' gradientUnits=\\'userSpaceOnUse\\' cx=\\'0\\' cy=\\'0\\' r=\\'10\\' gradientTransform=\\'matrix(36 0 0 50 0 326)\\'><stop stop-color=\\'rgba(0,255,136,0.22)\\' offset=\\'0\\'/><stop stop-color=\\'rgba(0,255,136,0.08)\\' offset=\\'0.45\\'/><stop stop-color=\\'rgba(0,255,136,0)\\' offset=\\'1\\'/></radialGradient></defs></svg>')",
122-
}}
123-
>
124-
<div className="max-w-[1000px] w-full z-10 flex flex-col gap-[32px] items-center">
138+
<main className="relative flex-1 w-full">
139+
<DotPattern />
140+
141+
<div className="relative z-10 mx-auto w-full max-w-[1100px] px-4 sm:px-6 md:px-8 py-8 sm:py-10 md:py-12">
142+
<div className="flex flex-col gap-6 md:gap-8">
125143
{alert && (
126144
<StickyAlert
127145
type={alert.type}
@@ -130,12 +148,13 @@ export default function DashboardLayout({
130148
/>
131149
)}
132150

133-
{children}
151+
{/* If the route is policy-forbidden for this user, suppress the
152+
page content while the layout redirect is in flight. Prevents
153+
the data fetch + content render of a page they shouldn't see. */}
154+
{forbidden ? null : children}
134155
</div>
135156
</div>
136-
137-
<DotPattern />
138-
</div>
157+
</main>
139158
</div>
140159
);
141160
}

app/dashboard/loading.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Route-level Suspense fallback for /dashboard and its subroutes.
2+
// Renders the same body-skeleton shape as DashboardContainer's data-loading
3+
// state so the transition from chunk-load -> data-load -> content is visually
4+
// continuous (no jarring flash between three different loading widgets).
5+
//
6+
// The NavBar is provided by app/dashboard/layout.tsx and stays on-screen
7+
// throughout, so this file only fills the main content area.
8+
9+
import { Spinner } from "@/components/ui/spinner";
10+
11+
export default function DashboardChunkLoading() {
12+
return (
13+
<div className="flex flex-col gap-6 w-full">
14+
{/* Status strip skeleton */}
15+
<div className="relative w-full rounded-lg overflow-hidden border border-[var(--border-soft)] bg-surface-1/90 p-5 sm:p-6">
16+
<div className="flex flex-col gap-4">
17+
<div className="h-3 w-48 rounded bg-surface-3 animate-pulse" />
18+
<div className="h-7 w-28 rounded-md bg-surface-3 animate-pulse" />
19+
<div className="h-8 w-3/4 rounded bg-surface-3 animate-pulse" />
20+
</div>
21+
</div>
22+
23+
{/* Timer + grid skeletons */}
24+
<div className="h-24 rounded-lg border border-[var(--border-soft)] bg-surface-1/90 animate-pulse" />
25+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5 md:gap-6">
26+
<div className="lg:col-span-2 flex flex-col gap-5">
27+
<div className="h-40 rounded-lg border border-[var(--border-soft)] bg-surface-1/90 animate-pulse" />
28+
<div className="h-56 rounded-lg border border-[var(--border-soft)] bg-surface-1/90 animate-pulse" />
29+
</div>
30+
<div className="flex flex-col gap-5">
31+
<div className="h-48 rounded-lg border border-[var(--border-soft)] bg-surface-1/90 animate-pulse" />
32+
<div className="h-32 rounded-lg border border-[var(--border-soft)] bg-surface-1/90 animate-pulse" />
33+
</div>
34+
</div>
35+
36+
<div className="flex items-center justify-center gap-3 py-2">
37+
<Spinner size="sm" />
38+
<span className="font-mono text-[10.5px] uppercase tracking-[0.3em] text-brand opacity-70">
39+
Booting operator terminal...
40+
</span>
41+
</div>
42+
</div>
43+
);
44+
}

app/dashboard/resume/page.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"use client";
2+
3+
import { useSearchParams, useRouter } from "next/navigation";
4+
import { useState } from "react";
5+
import { Button } from "@/components/registration/button";
6+
import { Download, ArrowLeft, ExternalLink, AlertOctagon } from "lucide-react";
7+
import { Spinner } from "@/components/ui/spinner";
8+
9+
export default function ResumeViewPage() {
10+
const params = useSearchParams();
11+
const router = useRouter();
12+
const rawUrl = params.get("url") || "";
13+
const ownerName = params.get("name") || "Operator";
14+
15+
const [iframeLoading, setIframeLoading] = useState(true);
16+
const [iframeError, setIframeError] = useState(false);
17+
18+
const proxiedUrl = rawUrl
19+
? `/api/resume/view?url=${encodeURIComponent(rawUrl)}`
20+
: "";
21+
22+
if (!proxiedUrl) {
23+
return (
24+
<div className="w-full flex flex-col items-center justify-center py-20 gap-4 text-center">
25+
<AlertOctagon className="w-8 h-8 text-[var(--danger)]" />
26+
<h1 className="font-heading text-[20px] font-semibold text-ink">
27+
Missing resume URL
28+
</h1>
29+
<p className="text-[13px] text-ink-muted font-body max-w-[42ch]">
30+
The resume viewer needs a resume URL passed as the{" "}
31+
<code className="font-mono text-brand">?url=</code> query parameter.
32+
</p>
33+
<Button onClick={() => router.back()} variant="secondary">
34+
<ArrowLeft className="w-3.5 h-3.5" />
35+
Go back
36+
</Button>
37+
</div>
38+
);
39+
}
40+
41+
return (
42+
<div className="w-full flex flex-col gap-4">
43+
{/* Header strip */}
44+
<div className="flex items-center justify-between gap-3 flex-wrap">
45+
<div className="flex flex-col gap-1 min-w-0">
46+
<div className="font-mono text-[10.5px] uppercase tracking-[0.22em] text-brand">
47+
&gt; viewing resume
48+
</div>
49+
<h1 className="font-heading text-[22px] sm:text-[26px] font-bold text-ink tracking-tight truncate">
50+
{ownerName}&apos;s resume
51+
</h1>
52+
</div>
53+
<div className="flex items-center gap-2">
54+
<Button onClick={() => router.back()} variant="secondary" size="sm">
55+
<ArrowLeft className="w-3.5 h-3.5" />
56+
Back
57+
</Button>
58+
<Button
59+
onClick={() => window.open(proxiedUrl, "_blank", "noopener,noreferrer")}
60+
variant="secondary"
61+
size="sm"
62+
>
63+
<ExternalLink className="w-3.5 h-3.5" />
64+
Raw tab
65+
</Button>
66+
<Button
67+
onClick={() => {
68+
const link = document.createElement("a");
69+
link.href = proxiedUrl;
70+
link.download = `${ownerName.replace(/\s+/g, "_")}_resume.pdf`;
71+
document.body.appendChild(link);
72+
link.click();
73+
document.body.removeChild(link);
74+
}}
75+
variant="primary"
76+
size="sm"
77+
>
78+
<Download className="w-3.5 h-3.5" />
79+
Download
80+
</Button>
81+
</div>
82+
</div>
83+
84+
{/* PDF frame */}
85+
<div className="relative w-full rounded-lg border border-[var(--border-soft)] bg-surface-1 shadow-card overflow-hidden">
86+
{iframeLoading && !iframeError && (
87+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10 bg-surface-1">
88+
<Spinner size="lg" />
89+
<span className="font-mono text-[10.5px] uppercase tracking-[0.3em] text-brand opacity-70">
90+
Decrypting document...
91+
</span>
92+
</div>
93+
)}
94+
{iframeError && (
95+
<div className="flex flex-col items-center justify-center gap-3 py-24 px-6 text-center">
96+
<AlertOctagon className="w-8 h-8 text-[var(--danger)]" />
97+
<h2 className="font-heading text-[18px] font-semibold text-ink">
98+
Could not preview the resume
99+
</h2>
100+
<p className="text-[13px] text-ink-muted font-body max-w-[44ch]">
101+
Your browser may have blocked the embed. Try opening the raw tab
102+
or downloading the PDF.
103+
</p>
104+
</div>
105+
)}
106+
<iframe
107+
src={proxiedUrl}
108+
title={`${ownerName}'s resume`}
109+
className={[
110+
"w-full h-[78vh] min-h-[640px] bg-surface-inset",
111+
iframeError ? "hidden" : "block",
112+
].join(" ")}
113+
onLoad={() => setIframeLoading(false)}
114+
onError={() => {
115+
setIframeLoading(false);
116+
setIframeError(true);
117+
}}
118+
/>
119+
</div>
120+
</div>
121+
);
122+
}

0 commit comments

Comments
 (0)