+ |
{app.displayNumber ?? '—'}
|
-
+ |
{app.name}
{app.email}
|
-
+ |
{app.majors || '—'}
|
- {app.graduationYear ?? '—'} |
- {app.readCount} |
- {formatScore(app.avgScore)} |
- {formatScore(app.normalizedAvgScore)} |
-
+ |
+ {app.graduationYear ?? '—'}
+ |
+
+ {app.readCount}
+ |
+
+ {formatScore(app.avgScore)}
+ |
+
+ {formatScore(app.normalizedAvgScore)}
+ |
+
Add brother
- setEmail(event.target.value)}
- placeholder="uniqname@umich.edu"
+ onChange={setEmail}
+ placeholder="uniqname"
className={inputClass}
required
/>
diff --git a/src/components/admin/BrotherTypeahead.tsx b/src/components/admin/BrotherTypeahead.tsx
new file mode 100644
index 0000000..d42a67d
--- /dev/null
+++ b/src/components/admin/BrotherTypeahead.tsx
@@ -0,0 +1,140 @@
+'use client'
+
+import { useEffect, useId, useRef, useState } from 'react'
+import { searchBrothersAction } from '@/app/admin/actions'
+import type { BrotherSearchHit } from '@/lib/brother-schema'
+
+const DEBOUNCE_MS = 250
+const MIN_CHARS = 2
+
+export function BrotherTypeahead({
+ id,
+ value,
+ onChange,
+ className,
+ placeholder = 'uniqname',
+ required,
+ disabled,
+}: {
+ id?: string
+ value: string
+ onChange: (value: string) => void
+ className?: string
+ placeholder?: string
+ required?: boolean
+ disabled?: boolean
+}) {
+ const listId = useId()
+ const rootRef = useRef(null)
+ const [open, setOpen] = useState(false)
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const requestId = useRef(0)
+
+ useEffect(() => {
+ const query = value.trim()
+ if (query.length < MIN_CHARS) {
+ setResults([])
+ setLoading(false)
+ return
+ }
+
+ setLoading(true)
+ const timer = window.setTimeout(() => {
+ const id = ++requestId.current
+ void searchBrothersAction(query).then((result) => {
+ if (id !== requestId.current) return
+ setLoading(false)
+ if (result.error || !result.data) {
+ setResults([])
+ return
+ }
+ setResults(result.data)
+ })
+ }, DEBOUNCE_MS)
+
+ return () => window.clearTimeout(timer)
+ }, [value])
+
+ useEffect(() => {
+ function onPointerDown(event: MouseEvent) {
+ if (!rootRef.current?.contains(event.target as Node)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ return () => document.removeEventListener('mousedown', onPointerDown)
+ }, [])
+
+ function pick(hit: BrotherSearchHit) {
+ onChange(hit.uniqname)
+ setOpen(false)
+ setResults([])
+ setLoading(false)
+ requestId.current += 1
+ }
+
+ const showList = open && value.trim().length >= MIN_CHARS && (loading || results.length > 0)
+
+ return (
+
+ {
+ onChange(event.target.value)
+ setOpen(true)
+ }}
+ onFocus={() => {
+ if (results.length > 0) setOpen(true)
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape') setOpen(false)
+ }}
+ />
+ {showList ? (
+
+ {loading && results.length === 0 ? (
+ - Searching…
+ ) : (
+ results.map((hit) => {
+ const name = [hit.first_name, hit.last_name].filter(Boolean).join(' ').trim()
+ return (
+ -
+
+
+ )
+ })
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/apply/ApplyShell.tsx b/src/components/apply/ApplyShell.tsx
index 411cb60..663ae88 100644
--- a/src/components/apply/ApplyShell.tsx
+++ b/src/components/apply/ApplyShell.tsx
@@ -1,6 +1,7 @@
'use client'
import { ApplyToast } from '@/components/apply/ApplyToast'
+import { SignedInAccountBar } from '@/components/SignedInAccountBar'
import { APPLY_STEPS, type ApplyStepSlug } from '@/lib/apply-steps'
export const applyCardClass =
@@ -51,6 +52,7 @@ export function ApplyShell({
return (
+
{title ? (
value.endsWith('@umich.edu'), 'Admin emails must be @umich.edu')
+export const adminEmailSchema = umichEmailSchema
export function parseAdminEmail(input: unknown) {
const result = adminEmailSchema.safeParse(input)
if (!result.success) {
- return { data: null, error: result.error.issues[0]?.message ?? 'Invalid email' }
+ return { data: null, error: result.error.issues[0]?.message ?? 'Invalid uniqname' }
}
return { data: result.data, error: null }
}
diff --git a/src/lib/brother-schema.ts b/src/lib/brother-schema.ts
index 19d5b3c..9f0623e 100644
--- a/src/lib/brother-schema.ts
+++ b/src/lib/brother-schema.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { umichEmailSchema } from '@/lib/umich-email'
const emptyToNull = (value: string | null | undefined) => {
if (value == null) return null
@@ -9,14 +10,7 @@ const emptyToNull = (value: string | null | undefined) => {
export const brotherWriteSchema = z.object({
first_name: z.string().trim().min(1, 'First name is required').max(80),
last_name: z.string().trim().min(1, 'Last name is required').max(80),
- umich_email: z
- .string()
- .trim()
- .min(1, 'UMich email is required')
- .max(320)
- .toLowerCase()
- .email('Enter a valid UMich email')
- .refine((value) => value.endsWith('@umich.edu'), 'UMich email must be @umich.edu'),
+ umich_email: umichEmailSchema,
pledge_class: z.string().trim().min(1, 'Pledge class is required').max(40),
linkedin_url: z
.string()
@@ -62,6 +56,15 @@ export type ClientBrother = {
pledge_class: string | null
}
+export type BrotherSearchHit = {
+ id: string
+ first_name: string | null
+ last_name: string | null
+ umich_email: string
+ uniqname: string
+ pledge_class: string | null
+}
+
export function parseBrotherWrite(input: unknown) {
const result = brotherWriteSchema.safeParse(input)
if (!result.success) {
diff --git a/src/lib/brothers.ts b/src/lib/brothers.ts
index e20160f..648baa7 100644
--- a/src/lib/brothers.ts
+++ b/src/lib/brothers.ts
@@ -1,11 +1,11 @@
import 'server-only'
-import { and, asc, eq, ne, sql } from 'drizzle-orm'
+import { and, asc, eq, ilike, ne, or, sql } from 'drizzle-orm'
import { db } from '@/db'
import { admins, brothers } from '@/db/schema'
-import type { BrotherWrite, ClientBrother } from '@/lib/brother-schema'
+import type { BrotherWrite, ClientBrother, BrotherSearchHit } from '@/lib/brother-schema'
-export type { ClientBrother }
+export type { ClientBrother, BrotherSearchHit }
export function toClientBrother(row: typeof brothers.$inferSelect): ClientBrother {
return {
@@ -26,6 +26,57 @@ export function brotherDisplayName(brother: ClientBrother, fallbackEmail: string
return name || fallbackEmail
}
+function uniqnameFromEmail(email: string) {
+ return email.replace(/@umich\.edu$/i, '')
+}
+
+export async function searchBrothers(query: string, limit = 8): Promise {
+ const trimmed = query.trim().toLowerCase()
+ if (trimmed.length < 2) return []
+
+ const safe = trimmed.replace(/[%_\\]/g, '')
+ if (safe.length < 2) return []
+ const pattern = `%${safe}%`
+
+ const rows = await db
+ .select({
+ id: brothers.id,
+ firstName: brothers.firstName,
+ lastName: brothers.lastName,
+ umichEmail: brothers.umichEmail,
+ pledgeClass: brothers.pledgeClass,
+ })
+ .from(brothers)
+ .where(
+ and(
+ sql`${brothers.umichEmail} is not null`,
+ or(
+ ilike(brothers.firstName, pattern),
+ ilike(brothers.lastName, pattern),
+ ilike(brothers.umichEmail, pattern),
+ sql`concat_ws(' ', ${brothers.firstName}, ${brothers.lastName}) ilike ${pattern}`
+ )
+ )
+ )
+ .orderBy(
+ sql`lower(coalesce(${brothers.firstName}, ''))`,
+ sql`lower(coalesce(${brothers.lastName}, ''))`,
+ asc(brothers.umichEmail)
+ )
+ .limit(limit)
+
+ return rows
+ .filter((row): row is typeof row & { umichEmail: string } => Boolean(row.umichEmail))
+ .map((row) => ({
+ id: row.id,
+ first_name: row.firstName,
+ last_name: row.lastName,
+ umich_email: row.umichEmail,
+ uniqname: uniqnameFromEmail(row.umichEmail),
+ pledge_class: row.pledgeClass,
+ }))
+}
+
export async function getBrotherByUmichEmail(email: string) {
const normalized = email.trim().toLowerCase()
if (!normalized) return null
@@ -113,7 +164,11 @@ export async function removeBrotherRow(id: string, actorEmail: string) {
return { error: 'You cannot remove yourself' as const }
}
if (row.umichEmail) {
- const [admin] = await db.select({ email: admins.email }).from(admins).where(eq(admins.email, row.umichEmail)).limit(1)
+ const [admin] = await db
+ .select({ email: admins.email })
+ .from(admins)
+ .where(eq(admins.email, row.umichEmail))
+ .limit(1)
if (admin) return { error: 'Remove their admin access first' as const }
}
diff --git a/src/lib/review-access-admin.ts b/src/lib/review-access-admin.ts
index 1295b68..86d16bb 100644
--- a/src/lib/review-access-admin.ts
+++ b/src/lib/review-access-admin.ts
@@ -5,6 +5,7 @@ import { db } from '@/db'
import { brothers, reviewAccess } from '@/db/schema'
import { MIN_REQUIRED_REVIEWS } from '@/lib/reviews'
import { normalizeReviewEmail } from '@/lib/review-access'
+import { umichEmailSchema } from '@/lib/umich-email'
export type ClientReviewAccess = {
id: string
@@ -55,10 +56,14 @@ export async function addReviewAccessEntry(input: {
email: string
minRequiredReviews?: number
}) {
- const email = normalizeReviewEmail(input.email)
- if (!email.endsWith('@umich.edu')) {
- return { entry: null, error: 'Reviewers must use a @umich.edu email.' as const }
+ const parsedEmail = umichEmailSchema.safeParse(input.email)
+ if (!parsedEmail.success) {
+ return {
+ entry: null,
+ error: parsedEmail.error.issues[0]?.message ?? 'Enter a valid uniqname.' as const,
+ }
}
+ const email = normalizeReviewEmail(parsedEmail.data)
const minRequiredReviews = input.minRequiredReviews ?? MIN_REQUIRED_REVIEWS
if (!Number.isInteger(minRequiredReviews) || minRequiredReviews < 1) {
diff --git a/src/lib/umich-email.ts b/src/lib/umich-email.ts
new file mode 100644
index 0000000..e735547
--- /dev/null
+++ b/src/lib/umich-email.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod'
+
+/** Accept `uniqname` or `uniqname@umich.edu`; always return full lowercased email. */
+export function toUmichEmail(input: string) {
+ const trimmed = input.trim().toLowerCase()
+ if (!trimmed) return ''
+ if (trimmed.includes('@')) return trimmed
+ return `${trimmed}@umich.edu`
+}
+
+export const umichEmailSchema = z
+ .string()
+ .trim()
+ .min(1, 'Uniqname is required')
+ .max(320, 'Email is too long')
+ .transform(toUmichEmail)
+ .pipe(
+ z
+ .string()
+ .email('Enter a valid uniqname or @umich.edu email')
+ .refine((value) => value.endsWith('@umich.edu'), 'Must be a @umich.edu address')
+ .refine((value) => {
+ const uniqname = value.slice(0, -'@umich.edu'.length)
+ return /^[a-z0-9][a-z0-9._-]*$/.test(uniqname)
+ }, 'Enter a valid uniqname')
+ )
From 179b66d6a9724c493f597aba1dd6f695db8f64d2 Mon Sep 17 00:00:00 2001
From: In Lorthongpanich
Date: Tue, 25 Aug 2026 01:22:44 +0700
Subject: [PATCH 16/20] Add dark brother portal chrome for login and /portal
---
src/app/login/page.tsx | 137 +++++++++++++------------
src/app/portal/page.tsx | 58 ++++++-----
src/components/HamburgerHeader.tsx | 151 ++++++++++++++++++++--------
src/components/Header.tsx | 29 ++++--
src/components/PortalDarkChrome.tsx | 21 ++++
src/components/PortalShell.tsx | 115 ++++++++++++++++++---
src/components/WideHeader.tsx | 76 ++++++++++----
7 files changed, 410 insertions(+), 177 deletions(-)
create mode 100644 src/components/PortalDarkChrome.tsx
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index bcddc4d..7cf6ec8 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -1,79 +1,86 @@
-'use client';
+'use client'
-import { createClient } from '../../lib/supabase/client';
-import Header from '../../components/Header';
+import Link from 'next/link'
+import { useSearchParams } from 'next/navigation'
+import { Suspense, useEffect } from 'react'
+import Header from '@/components/Header'
+import { UmichGoogleButton } from '@/components/apply/UmichGoogleButton'
-export default function Login() {
- const supabase = createClient();
+function LoginContent() {
+ const searchParams = useSearchParams()
+ const authFailed = searchParams.get('error') === 'auth_failed'
+
+ return (
+
+
+ Brother Portal
+
+
+ Sign in with your UMich Google account to open the brother portal. Applicants should use the
+ rush application instead.
+
- const handleGoogleLogin = async () => {
- const { error } = await supabase.auth.signInWithOAuth({
- provider: 'google',
- options: {
- redirectTo: `${window.location.origin}/auth/callback`,
- queryParams: {
- hd: 'umich.edu', // Restrict to @umich.edu emails
- },
- },
- });
+ {authFailed ? (
+ Sign-in failed. Please try again.
+ ) : null}
- if (error) {
- console.error('Error signing in with Google:', error);
- alert('Failed to sign in. Please try again.');
+
+
+
+ Applying this cycle?{' '}
+
+ Go to the rush application
+
+
+
+ )
+}
+
+export default function Login() {
+ useEffect(() => {
+ const root = document.documentElement
+ const body = document.body
+ const prevRoot = root.style.backgroundColor
+ const prevBody = body.style.backgroundColor
+ root.style.backgroundColor = '#0f172a'
+ body.style.backgroundColor = '#0f172a'
+ return () => {
+ root.style.backgroundColor = prevRoot
+ body.style.backgroundColor = prevBody
}
- };
+ }, [])
return (
-
-
+
+
-
- {/* Blob Container */}
-
-
-
+
+
- {/* Page content */}
-
-
-
-
- Welcome!
-
-
-
-
-
+
+
+
+
- );
+ )
}
-
diff --git a/src/app/portal/page.tsx b/src/app/portal/page.tsx
index 1b27bea..fa8b460 100644
--- a/src/app/portal/page.tsx
+++ b/src/app/portal/page.tsx
@@ -1,13 +1,13 @@
import Link from 'next/link'
-import { TimeGreeting } from '@/components/portal/TimeGreeting'
+import { SignedInAccountBar } from '@/components/SignedInAccountBar'
// import { AlumniDirectoryTable } from '@/components/portal/AlumniDirectoryTable'
// import { InterviewHomeSection } from '@/components/portal/InterviewHomeSection'
import {
PortalShell,
- portalInnerCardClass,
- portalInnerCardStyle,
- portalSectionCardClass,
- portalSectionCardStyle,
+ portalDarkInnerCardClass,
+ portalDarkInnerCardStyle,
+ portalDarkSectionCardClass,
+ portalDarkSectionCardStyle,
} from '@/components/PortalShell'
import { checkIsAdmin } from '@/lib/supabase/auth-helpers'
import { requirePortalUser } from '@/lib/portal'
@@ -22,40 +22,47 @@ type QuickLink = {
}
function LinkRow({ link }: { link: QuickLink }) {
- const className = `${portalInnerCardClass} cursor-pointer transition-all duration-200 hover:bg-white`
+ const className = `${portalDarkInnerCardClass} cursor-pointer`
const body = (
<>
- {link.title}
- {link.subtitle}
+ {link.title}
+ {link.subtitle}
- {link.external ? 'Open' : 'View'}
+
+ {link.external ? 'Open' : 'View'}
+
>
)
if (link.external) {
return (
-
+
{body}
)
}
return (
-
+
{body}
)
}
export default async function PortalPage() {
- const { email, brother } = await requirePortalUser()
+ const { email } = await requirePortalUser()
const adminUser = await checkIsAdmin()
const activeCycle = await getActiveCycle()
const showApplicationReads = activeCycle
? await canReviewApplications({ email, cycleId: activeCycle.id })
: false
- const greetingName = brother.first_name?.trim() || email
const quickLinks: QuickLink[] = [
{
@@ -83,18 +90,21 @@ export default async function PortalPage() {
return (
}
+ subtitle={
+
+ }
headerRight={

}
>
-
- Quick Links
+
+ Quick Links
{quickLinks.map((link) => (
@@ -103,8 +113,8 @@ export default async function PortalPage() {
{showApplicationReads ? (
-
- Your Tasks
+
+ Your Tasks
) : null}
-
- More coming soon
- More features coming soon. Stay tuned.
+
+ More coming soon
+ More features coming soon. Stay tuned.
- {/*
+ {/*
-
+
*/}
diff --git a/src/components/HamburgerHeader.tsx b/src/components/HamburgerHeader.tsx
index 9f20295..67afbeb 100644
--- a/src/components/HamburgerHeader.tsx
+++ b/src/components/HamburgerHeader.tsx
@@ -3,84 +3,147 @@
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
-export default function HamburgerHeader() {
+export default function HamburgerHeader({ tone = 'light' }: { tone?: 'light' | 'dark' }) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
- const menuRef = useRef (null); // Reference for the nav menu
+ const menuRef = useRef(null);
+ const isDark = tone === 'dark';
- const toggleMenu = () => {
- setIsMenuOpen(!isMenuOpen);
- };
-
- // Function to close the menu
const closeMenu = () => {
setIsMenuOpen(false);
};
- // Setup an event listener to close the menu when clicking outside of it
useEffect(() => {
+ if (isDark) return;
+
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
- closeMenu(); // Close the menu if clicked outside
+ closeMenu();
}
};
- // Add the event listener
- document.addEventListener("mousedown", handleClickOutside);
-
- // Clean up the event listener on component unmount
+ document.addEventListener('mousedown', handleClickOutside);
return () => {
- document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener('mousedown', handleClickOutside);
};
- }, []); // Empty dependency array ensures the effect runs only on mount and unmount
+ }, [isDark]);
+
+ if (isDark) {
+ return (
+
+
+
+ 
+
+
+
+ 
+
+ KTP Life App
+
+
+
+ );
+ }
+
+ const toggleMenu = () => {
+ setIsMenuOpen(!isMenuOpen);
+ };
return (
-
- {/* Logo */}
-
-
- 
- {/*  */}
+
+
+
+
- {/* Hamburger Menu */}
-
- |