diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 3a022bb..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"], - "rules": { - // Escrow/payout service boundaries are hard (ADR-001): vendor SDKs are - // only reachable through lib/ports. Kept as a warning until the boundary - // import lint rule lands in Phase 3. - "no-restricted-imports": [ - "warn", - { - "patterns": [ - { - "group": ["ethers"], - "message": "Vendor SDKs must be accessed via lib/ports — never imported directly (ADR-001)." - } - ] - } - ] - } -} diff --git a/.gitignore b/.gitignore index 6600e5a..628c135 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ next-env.d.ts # Claude Code local settings (per-machine, never committed) .claude/ +.npmrc diff --git a/app/(app)/organizer/hackathons/[slug]/media/page.tsx b/app/(app)/organizer/hackathons/[slug]/media/page.tsx index 639caaa..f3bb7c4 100644 --- a/app/(app)/organizer/hackathons/[slug]/media/page.tsx +++ b/app/(app)/organizer/hackathons/[slug]/media/page.tsx @@ -31,9 +31,15 @@ export default async function EventMediaPage({ if (!membership || membership.role === "MEMBER") notFound(); const deadline = event.mediaDeadlineAt; + // Server Component: this runs once per server request, not a client + // re-render, so Date.now() is the intended per-request deadline check + // (react-hooks/purity is new in eslint-config-next 16 and doesn't + // distinguish RSC render from client render). const hoursLeft = deadline - ? Math.round((deadline.getTime() - Date.now()) / 3_600_000) + ? // eslint-disable-next-line react-hooks/purity -- see comment above + Math.round((deadline.getTime() - Date.now()) / 3_600_000) : null; + // eslint-disable-next-line react-hooks/purity -- see comment above const pastDeadline = deadline ? deadline.getTime() < Date.now() : false; const approvedCount = event.media.filter((asset) => asset.status === "APPROVED").length; diff --git a/app/(auth)/invites/[token]/page.tsx b/app/(auth)/invites/[token]/page.tsx index 0142005..f21ddfe 100644 --- a/app/(auth)/invites/[token]/page.tsx +++ b/app/(auth)/invites/[token]/page.tsx @@ -14,6 +14,10 @@ export default async function InvitePage({ params }: { params: Promise<{ token: if ( !invitation || invitation.acceptedAt || + // Server Component: runs once per server request, so Date.now() is the + // intended per-request expiry check (react-hooks/purity is new in + // eslint-config-next 16 and doesn't distinguish RSC render from client). + // eslint-disable-next-line react-hooks/purity -- see comment above invitation.expiresAt.getTime() <= Date.now() ) { return ; diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index 4437db9..19a16b3 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -91,7 +91,7 @@ export default async function BlogPostPage({ params }: PageProps) { src={meta.cover} alt={meta.coverAlt} fill - priority + preload sizes="(min-width: 1440px) 1392px, 100vw" className="object-cover" /> diff --git a/app/(marketing)/hackathons/[slug]/page.tsx b/app/(marketing)/hackathons/[slug]/page.tsx index 912d9fe..782fb50 100644 --- a/app/(marketing)/hackathons/[slug]/page.tsx +++ b/app/(marketing)/hackathons/[slug]/page.tsx @@ -246,7 +246,7 @@ export default async function EventDetailPage({ params, searchParams }: PageProp src={cover} alt={`${event.title} hackathon cover`} fill - priority + preload sizes="(min-width: 1024px) 380px, 100vw" unoptimized={!cover.startsWith("/")} className="object-cover" diff --git a/components/auth/oauth-popup-pages.tsx b/components/auth/oauth-popup-pages.tsx index cd9607c..11b5150 100644 --- a/components/auth/oauth-popup-pages.tsx +++ b/components/auth/oauth-popup-pages.tsx @@ -52,7 +52,10 @@ export function OAuthPopupDone() { const [orphaned, setOrphaned] = useState(false); useEffect(() => { if (!reportToOpener({ type: OAUTH_POPUP_NAME, status: "success" })) { - // Opened outside the popup flow (or the original tab was closed): carry on here. + // Opened outside the popup flow (or the original tab was closed): carry + // on here. This relays a one-time external result, not reactive state + // (react-hooks/set-state-in-effect is new in eslint-config-next 16). + // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above setOrphaned(true); window.location.replace("/dashboard"); } diff --git a/components/hackathons/hackathon-card.tsx b/components/hackathons/hackathon-card.tsx index d5a33a1..6718a82 100644 --- a/components/hackathons/hackathon-card.tsx +++ b/components/hackathons/hackathon-card.tsx @@ -35,7 +35,7 @@ export function HackathonCover({ // Uploaded covers live on R2 and are already sized to 1600x900. unoptimized={!cover.startsWith("/")} alt={`${event.title}, a hackathon on HackVillage`} - priority={priority} + preload={priority} fill sizes={sizes} className={cn( diff --git a/components/judging/judging-section.tsx b/components/judging/judging-section.tsx index 00f59b3..896cfdf 100644 --- a/components/judging/judging-section.tsx +++ b/components/judging/judging-section.tsx @@ -26,6 +26,10 @@ export async function JudgingSection({ status: EventStatus; }) { const readiness = await judgingReadiness(eventId); + // Server Component: runs once per server request, so Date.now() is the + // intended per-request "has the event ended" check (react-hooks/purity is + // new in eslint-config-next 16 and doesn't distinguish RSC from client). + // eslint-disable-next-line react-hooks/purity -- see comment above const ended = endsAt.getTime() <= Date.now(); const judging = status === "JUDGING"; diff --git a/components/patterns/app-sidebar.tsx b/components/patterns/app-sidebar.tsx index 5a50061..96499c2 100644 --- a/components/patterns/app-sidebar.tsx +++ b/components/patterns/app-sidebar.tsx @@ -138,6 +138,10 @@ export function AppTopBar({ const [mounted, setMounted] = useState(false); useEffect(() => { + // Hydration-safe mount flag: intentionally renders differently once the + // client has mounted (react-hooks/set-state-in-effect is new in + // eslint-config-next 16; this is the standard pattern for it). + // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above setMounted(true); }, []); diff --git a/components/patterns/auth-visual.tsx b/components/patterns/auth-visual.tsx index d02b1c4..d82f3e5 100644 --- a/components/patterns/auth-visual.tsx +++ b/components/patterns/auth-visual.tsx @@ -13,7 +13,7 @@ export function AuthVisual() { src="/marketing/auth/hackathon-builders-welcome.webp" alt="" fill - priority + preload sizes="50vw" className="object-cover object-top" /> diff --git a/components/patterns/legal-toc.tsx b/components/patterns/legal-toc.tsx index e4aa6ae..d9ae9b6 100644 --- a/components/patterns/legal-toc.tsx +++ b/components/patterns/legal-toc.tsx @@ -25,6 +25,10 @@ export function LegalToc({ containerId }: { containerId: string }) { headings.forEach((heading, index) => { heading.id = ids[index]; }); + // Builds the TOC from headings read out of the DOM after mount, which + // only exists client-side (react-hooks/set-state-in-effect is new in + // eslint-config-next 16 and flags this DOM-read-then-setState pattern). + // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above setEntries( headings.map((heading, index) => ({ id: ids[index], diff --git a/components/patterns/mobile-nav.tsx b/components/patterns/mobile-nav.tsx index e6d5951..af8309a 100644 --- a/components/patterns/mobile-nav.tsx +++ b/components/patterns/mobile-nav.tsx @@ -37,6 +37,10 @@ export function MobileNav({ navLinks, organizerLinks, trailingLinks }: MobileNav const [mounted, setMounted] = useState(false); useEffect(() => { + // Hydration-safe mount flag: intentionally renders differently once the + // client has mounted (react-hooks/set-state-in-effect is new in + // eslint-config-next 16; this is the standard pattern for it). + // eslint-disable-next-line react-hooks/set-state-in-effect -- see comment above setMounted(true); }, []); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..8f8d195 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,39 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +export default defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([ + ".next/**", + "out/**", + "build/**", + "coverage/**", + "next-env.d.ts", + // Hardhat output (contracts carry their own toolchain). + "artifacts/**", + "cache/**", + "typechain-types/**", + "public/uploads/**", + ]), + { + rules: { + // Escrow/payout service boundaries are hard (ADR-001): vendor SDKs are + // only reachable through lib/ports. Kept as a warning until the boundary + // import lint rule lands in Phase 3. + "no-restricted-imports": [ + "warn", + { + patterns: [ + { + group: ["ethers"], + message: + "Vendor SDKs must be accessed via lib/ports — never imported directly (ADR-001).", + }, + ], + }, + ], + }, + }, +]); diff --git a/lib/auth/guards.ts b/lib/auth/guards.ts index a0ed0be..a76b198 100644 --- a/lib/auth/guards.ts +++ b/lib/auth/guards.ts @@ -7,9 +7,8 @@ import { canAccessSurface, isOnboarded } from "@/lib/auth/rbac"; /** * Server-side guards (Phase 1). Layouts and server actions call these — - * enforcement NEVER lives in the client. Middleware is deliberately unused: - * the session check reads Prisma (revocation, roles), which doesn't belong in - * middleware; the layout tree is the single, stronger gate. + * enforcement NEVER lives in the client. proxy.ts only refreshes sessions and + * never decides access: the layout tree is the single, stronger gate. */ export interface CurrentUser extends SessionUserLike { diff --git a/lib/auth/index.ts b/lib/auth/index.ts index 9636339..9203aa8 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -135,7 +135,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // jwt callback ends any session whose version is stale or whose account is // gone (ADR-005 amended; see lib/auth/session-version.ts). // The cookie lives as long as the longest inactivity window and slides on - // every request (middleware.ts refreshes it). The exact per-role idle and + // every request (proxy.ts refreshes it). The exact per-role idle and // overall limits are enforced in the jwt callback (lib/auth/session-policy). session: { strategy: "jwt", maxAge: MAX_IDLE_SECONDS }, pages: { signIn: "/signin", error: "/signin" }, diff --git a/next.config.ts b/next.config.ts index 88f5e76..0c47ccb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -103,7 +103,7 @@ function sentryIngestOrigin(): string | null { * keeps 'unsafe-inline' because the App Router streams its RSC payload in * inline