feat(classroom): implement teacher classroom dashboard (Phase 3)

Add teacher classroom functionality allowing users to create a classroom
and manage students:

- Add classroom query keys for React Query cache management
- Create useClassroom hooks (useMyClassroom, useClassroomByCode,
  useCreateClassroom, useIsTeacher)
- Add classroom UI components:
  - ClassroomCodeShare: Display join code with copy button
  - CreateClassroomForm: Form to create a classroom
  - ClassroomTab: Live classroom view (empty state for Phase 6)
  - StudentManagerTab: Enrolled students list (empty state for Phase 4)
  - ClassroomDashboard: Main teacher dashboard with tabs
- Integrate into PracticeClient with conditional routing:
  - Teachers see ClassroomDashboard with own children shown separately
  - Parents see normal student list with "Become a Teacher" option
- Fix API route to remove non-existent 'image' field from User type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-22 13:07:40 -06:00
parent 02842270c9
commit 2202716f56
14 changed files with 1007 additions and 72 deletions

View File

@ -32,7 +32,6 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
? {
id: classroom.teacher.id,
name: classroom.teacher.name,
image: classroom.teacher.image,
}
: null,
})

View File

@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
/**
* GET /api/classrooms/mine
* Get current user's classroom (if teacher)
@ -11,8 +29,9 @@ import { getViewerId } from '@/lib/viewer'
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom) {
return NextResponse.json({ error: 'No classroom found' }, { status: 404 })

View File

@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { createClassroom, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
/**
* GET /api/classrooms
* Get current user's classroom (alias for /api/classrooms/mine)
@ -11,8 +29,9 @@ import { getViewerId } from '@/lib/viewer'
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
return NextResponse.json({ classroom })
} catch (error) {
@ -31,6 +50,7 @@ export async function GET() {
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.name) {
@ -38,7 +58,7 @@ export async function POST(req: NextRequest) {
}
const result = await createClassroom({
teacherId: viewerId,
teacherId: user.id,
name: body.name,
})

View File

@ -13,10 +13,7 @@ async function getOrCreateUser(viewerId: string) {
})
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({ guestId: viewerId })
.returning()
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}

View File

@ -26,10 +26,7 @@ export async function GET() {
let players
if (linkedIds.length > 0) {
players = await db.query.players.findMany({
where: or(
eq(schema.players.userId, user.id),
inArray(schema.players.id, linkedIds)
),
where: or(eq(schema.players.userId, user.id), inArray(schema.players.id, linkedIds)),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
} else {

View File

@ -3,10 +3,12 @@
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { Z_INDEX } from '@/constants/zIndex'
import { ClassroomDashboard, CreateClassroomForm } from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
import { useTheme } from '@/contexts/ThemeContext'
import { useMyClassroom } from '@/hooks/useClassroom'
import { usePlayersWithSkillData, useUpdatePlayer } from '@/hooks/useUserPlayers'
import type { StudentWithSkillData } from '@/utils/studentGrouping'
import { filterStudents, getStudentsNeedingAttention, groupStudents } from '@/utils/studentGrouping'
@ -29,6 +31,10 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Classroom state - check if user is a teacher
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
const [showCreateClassroom, setShowCreateClassroom] = useState(false)
// Filter state
const [searchQuery, setSearchQuery] = useState('')
const [skillFilters, setSkillFilters] = useState<string[]>([])
@ -175,6 +181,61 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
.length
}, [players, searchQuery, skillFilters, showArchived])
// Handle classroom creation
const handleBecomeTeacher = useCallback(() => {
setShowCreateClassroom(true)
}, [])
const handleCloseCreateClassroom = useCallback(() => {
setShowCreateClassroom(false)
}, [])
// If user is a teacher, show the classroom dashboard
if (classroom) {
return (
<PageWithNav>
<main
data-component="practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
})}
>
<ClassroomDashboard classroom={classroom} ownChildren={players} />
</main>
</PageWithNav>
)
}
// Show create classroom modal if requested
if (showCreateClassroom) {
return (
<PageWithNav>
<main
data-component="practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
})}
>
<div
className={css({
maxWidth: '500px',
width: '100%',
})}
>
<CreateClassroomForm onCancel={handleCloseCreateClassroom} />
</div>
</main>
</PageWithNav>
)
}
// Parent view - show student list with filter bar
return (
<PageWithNav>
<main
@ -233,6 +294,33 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
>
Build your soroban skills one step at a time
</p>
{/* Become a Teacher option */}
{!isLoadingClassroom && !classroom && (
<button
type="button"
onClick={handleBecomeTeacher}
data-action="become-teacher"
className={css({
marginTop: '16px',
padding: '8px 16px',
backgroundColor: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
🏫 Are you a teacher? Create a classroom
</button>
)}
</header>
{/* Needs Attention Section - uses same bucket styling as other sections */}

View File

@ -0,0 +1,149 @@
'use client'
import { useCallback, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
interface ClassroomCodeShareProps {
code: string
/** Compact display for inline use */
compact?: boolean
}
/**
* Display classroom join code with copy button
*/
export function ClassroomCodeShare({ code, compact = false }: ClassroomCodeShareProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = code
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [code])
if (compact) {
return (
<button
type="button"
data-action="copy-classroom-code"
onClick={handleCopy}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.200',
},
})}
title="Click to copy"
>
<span
className={css({
fontFamily: 'monospace',
fontSize: '0.875rem',
fontWeight: 'bold',
letterSpacing: '0.1em',
color: isDark ? 'blue.400' : 'blue.600',
})}
>
{code}
</span>
<span className={css({ fontSize: '0.875rem' })}>{copied ? '✓' : '📋'}</span>
</button>
)
}
return (
<div
data-component="classroom-code-share"
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '16px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ flex: 1 })}>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '4px',
})}
>
Classroom Code
</p>
<span
data-element="classroom-code"
className={css({
fontFamily: 'monospace',
fontSize: '1.5rem',
fontWeight: 'bold',
letterSpacing: '0.15em',
color: isDark ? 'blue.400' : 'blue.600',
})}
>
{code}
</span>
</div>
<button
type="button"
data-action="copy-classroom-code"
onClick={handleCopy}
className={css({
padding: '10px 16px',
backgroundColor: copied
? isDark
? 'green.700'
: 'green.500'
: isDark
? 'blue.700'
: 'blue.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: copied
? isDark
? 'green.600'
: 'green.600'
: isDark
? 'blue.600'
: 'blue.600',
},
})}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
)
}

View File

@ -0,0 +1,227 @@
'use client'
import { useState } from 'react'
import type { Classroom, Player } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
import { ClassroomTab } from './ClassroomTab'
import { StudentManagerTab } from './StudentManagerTab'
type TabId = 'classroom' | 'students'
interface ClassroomDashboardProps {
classroom: Classroom
/** Teacher's own children (get special "parent access" treatment) */
ownChildren?: Player[]
}
/**
* ClassroomDashboard - Main teacher dashboard
*
* Two tabs:
* - Classroom: Live view of present students
* - Student Manager: Enrolled students list with progress
*
* Teacher's own children appear separately with full parent access.
*/
export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDashboardProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [activeTab, setActiveTab] = useState<TabId>('classroom')
const tabs: { id: TabId; label: string; icon: string }[] = [
{ id: 'classroom', label: 'Classroom', icon: '🏫' },
{ id: 'students', label: 'Student Manager', icon: '👥' },
]
return (
<div
data-component="classroom-dashboard"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
padding: '24px',
maxWidth: '1000px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
'@media (min-width: 640px)': {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
})}
>
<div>
<h1
className={css({
fontSize: '1.75rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.900',
marginBottom: '4px',
})}
>
{classroom.name}
</h1>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Teacher Dashboard
</p>
</div>
<ClassroomCodeShare code={classroom.code} compact />
</header>
{/* Own children section (if teacher has kids) */}
{ownChildren.length > 0 && (
<section
className={css({
padding: '16px',
backgroundColor: isDark ? 'green.900/20' : 'green.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'green.800' : 'green.200',
})}
>
<h2
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
color: isDark ? 'green.300' : 'green.700',
marginBottom: '12px',
})}
>
Your Children
</h2>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
})}
>
{ownChildren.map((child) => (
<div
key={child.id}
data-element="own-child-card"
className={css({
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '10px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<span
className={css({
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: child.color }}
>
{child.emoji}
</span>
<span
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
})}
>
{child.name}
</span>
</div>
))}
</div>
</section>
)}
{/* Tab navigation */}
<nav
className={css({
display: 'flex',
gap: '4px',
backgroundColor: isDark ? 'gray.800' : 'gray.100',
padding: '4px',
borderRadius: '12px',
})}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
data-action={`select-tab-${tab.id}`}
data-active={activeTab === tab.id}
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '12px 16px',
borderRadius: '10px',
border: 'none',
fontSize: '0.9375rem',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
backgroundColor:
activeTab === tab.id ? (isDark ? 'gray.700' : 'white') : 'transparent',
color:
activeTab === tab.id
? isDark
? 'white'
: 'gray.900'
: isDark
? 'gray.400'
: 'gray.600',
boxShadow: activeTab === tab.id ? 'sm' : 'none',
_hover: {
backgroundColor:
activeTab === tab.id
? isDark
? 'gray.700'
: 'white'
: isDark
? 'gray.700/50'
: 'gray.200',
},
})}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</nav>
{/* Tab content */}
<main>
{activeTab === 'classroom' ? (
<ClassroomTab classroom={classroom} />
) : (
<StudentManagerTab classroom={classroom} />
)}
</main>
</div>
)
}

View File

@ -0,0 +1,116 @@
'use client'
import type { Classroom } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
interface ClassroomTabProps {
classroom: Classroom
}
/**
* ClassroomTab - Shows live classroom view
*
* Displays students currently "present" in the classroom.
* For Phase 3, this is an empty state.
* Phase 6 will add presence functionality.
*/
export function ClassroomTab({ classroom }: ClassroomTabProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<div
data-component="classroom-tab"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
})}
>
{/* Empty state */}
<div
className={css({
textAlign: 'center',
padding: '48px 24px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '16px',
border: '2px dashed',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
fontSize: '3rem',
marginBottom: '16px',
})}
>
🏫
</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
No Students Present
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '24px',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
})}
>
When students join your classroom for practice, they'll appear here. Share your classroom
code to get started.
</p>
<ClassroomCodeShare code={classroom.code} />
</div>
{/* Instructions */}
<div
className={css({
padding: '20px',
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.200',
})}
>
<h4
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.700',
marginBottom: '8px',
})}
>
How students join
</h4>
<ol
className={css({
fontSize: '0.875rem',
color: isDark ? 'blue.200' : 'blue.800',
paddingLeft: '20px',
listStyleType: 'decimal',
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<li>Share your classroom code with parents</li>
<li>Parents enroll their child using the code</li>
<li>Students appear here when they start practicing</li>
</ol>
</div>
</div>
)
}

View File

@ -0,0 +1,182 @@
'use client'
import { useCallback, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { useCreateClassroom } from '@/hooks/useClassroom'
import { css } from '../../../styled-system/css'
interface CreateClassroomFormProps {
onSuccess?: () => void
onCancel?: () => void
}
/**
* Form to create a new classroom (become a teacher)
*/
export function CreateClassroomForm({ onSuccess, onCancel }: CreateClassroomFormProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [name, setName] = useState('')
const createClassroom = useCreateClassroom()
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
try {
await createClassroom.mutateAsync({ name: name.trim() })
onSuccess?.()
} catch {
// Error is handled by mutation state
}
},
[name, createClassroom, onSuccess]
)
return (
<div
data-component="create-classroom-form"
className={css({
padding: '24px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
maxWidth: '400px',
})}
>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Create Your Classroom
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
As a teacher, you can enroll students and observe their practice sessions.
</p>
<form onSubmit={handleSubmit}>
<label
htmlFor="classroom-name"
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '6px',
})}
>
Classroom Name
</label>
<input
id="classroom-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Ms. Smith's Math Class"
data-input="classroom-name"
className={css({
width: '100%',
padding: '12px 14px',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
borderRadius: '8px',
fontSize: '1rem',
color: isDark ? 'white' : 'gray.800',
marginBottom: '16px',
outline: 'none',
transition: 'border-color 0.15s ease',
_focus: {
borderColor: isDark ? 'blue.500' : 'blue.400',
},
_placeholder: {
color: isDark ? 'gray.500' : 'gray.400',
},
})}
/>
{createClassroom.error && (
<p
className={css({
fontSize: '0.8125rem',
color: 'red.500',
marginBottom: '12px',
})}
>
{createClassroom.error.message}
</p>
)}
<div className={css({ display: 'flex', gap: '12px' })}>
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={createClassroom.isPending}
data-action="cancel-create-classroom"
className={css({
flex: 1,
padding: '12px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
Cancel
</button>
)}
<button
type="submit"
disabled={createClassroom.isPending || !name.trim()}
data-action="create-classroom"
className={css({
flex: 2,
padding: '12px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'green.600' : 'green.600',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{createClassroom.isPending ? 'Creating...' : 'Create Classroom'}
</button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,117 @@
'use client'
import type { Classroom } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
interface StudentManagerTabProps {
classroom: Classroom
}
/**
* StudentManagerTab - Manage enrolled students
*
* Shows all students enrolled in the classroom.
* For Phase 3, this is an empty state.
* Phase 4 will add enrollment functionality.
*/
export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<div
data-component="student-manager-tab"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
})}
>
{/* Empty state */}
<div
className={css({
textAlign: 'center',
padding: '48px 24px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '16px',
border: '2px dashed',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
fontSize: '3rem',
marginBottom: '16px',
})}
>
👥
</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
No Students Enrolled
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '24px',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
})}
>
Parents can enroll their children using your classroom code. Once enrolled, you can view
their progress and skills here.
</p>
<ClassroomCodeShare code={classroom.code} />
</div>
{/* What you'll see when students enroll */}
<div
className={css({
padding: '20px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h4
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '12px',
})}
>
When students enroll, you can:
</h4>
<ul
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
paddingLeft: '20px',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '6px',
})}
>
<li>View each student's skill mastery and progress</li>
<li>See their practice session history</li>
<li>Observe live practice sessions</li>
<li>Guide them through tutorials remotely</li>
</ul>
</div>
</div>
)
}

View File

@ -0,0 +1,5 @@
export { ClassroomCodeShare } from './ClassroomCodeShare'
export { ClassroomDashboard } from './ClassroomDashboard'
export { ClassroomTab } from './ClassroomTab'
export { CreateClassroomForm } from './CreateClassroomForm'
export { StudentManagerTab } from './StudentManagerTab'

View File

@ -1,76 +1,100 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Classroom } from '@/db/schema/classrooms'
import type { Classroom, User } from '@/db/schema'
import { api } from '@/lib/queryClient'
import { classroomKeys } from '@/lib/queryKeys'
// Query keys for classroom data
export const classroomKeys = {
all: ['classroom'] as const,
mine: () => [...classroomKeys.all, 'mine'] as const,
detail: (id: string) => [...classroomKeys.all, 'detail', id] as const,
byCode: (code: string) => [...classroomKeys.all, 'code', code] as const,
// Re-export query keys for consumers
export { classroomKeys } from '@/lib/queryKeys'
// ============================================================================
// Types
// ============================================================================
export interface ClassroomWithTeacher extends Classroom {
teacher?: User
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Fetch current user's classroom
*/
async function fetchMyClassroom(): Promise<Classroom | null> {
const res = await api('classrooms')
if (!res.ok) {
// 404 means no classroom
if (res.status === 404) return null
throw new Error('Failed to fetch classroom')
}
const res = await api('classrooms/mine')
if (res.status === 404) return null
if (!res.ok) throw new Error('Failed to fetch classroom')
const data = await res.json()
return data.classroom ?? null
}
/**
* Create a classroom
*/
async function createClassroom(name: string): Promise<Classroom> {
const res = await api('classrooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = await res.json()
if (!res.ok || !data.success) {
throw new Error(data.error || 'Failed to create classroom')
}
return data.classroom
}
/**
* Look up classroom by code
* Look up classroom by join code
*/
async function fetchClassroomByCode(
code: string
): Promise<{ classroom: Classroom; teacherName: string } | null> {
const res = await api(`classrooms/code/${code.toUpperCase()}`)
if (!res.ok) {
if (res.status === 404) return null
throw new Error('Failed to look up classroom')
}
async function fetchClassroomByCode(code: string): Promise<ClassroomWithTeacher | null> {
if (!code || code.length < 4) return null
const res = await api(`classrooms/code/${encodeURIComponent(code)}`)
if (res.status === 404) return null
if (!res.ok) throw new Error('Failed to fetch classroom')
const data = await res.json()
return data
return data.classroom
}
/**
* Hook: Get current user's classroom (if they're a teacher)
* Create a new classroom
*/
async function createClassroom(params: { name: string }): Promise<Classroom> {
const res = await api('classrooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to create classroom')
}
const data = await res.json()
return data.classroom
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Get current user's classroom (if they are a teacher)
*/
export function useMyClassroom() {
return useQuery({
queryKey: classroomKeys.mine(),
queryFn: fetchMyClassroom,
// Don't refetch too aggressively - classrooms rarely change
staleTime: 60_000, // 1 minute
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
/**
* Hook: Create a classroom
* Look up classroom by join code
*
* Use this when a parent wants to enroll their child.
* The query is disabled until code has at least 4 characters.
*/
export function useClassroomByCode(code: string) {
return useQuery({
queryKey: classroomKeys.byCode(code.toUpperCase()),
queryFn: () => fetchClassroomByCode(code),
enabled: code.length >= 4,
staleTime: 60 * 1000, // 1 minute
})
}
/**
* Create a new classroom
*
* Use this when a user wants to become a teacher.
* Each user can have only one classroom.
*/
export function useCreateClassroom() {
const queryClient = useQueryClient()
@ -78,34 +102,19 @@ export function useCreateClassroom() {
return useMutation({
mutationFn: createClassroom,
onSuccess: (classroom) => {
// Update the cache with the new classroom
// Update the 'mine' query with the new classroom
queryClient.setQueryData(classroomKeys.mine(), classroom)
// Invalidate to ensure consistency
queryClient.invalidateQueries({ queryKey: classroomKeys.all })
},
})
}
/**
* Hook: Look up classroom by join code
*/
export function useClassroomByCode(code: string | null) {
return useQuery({
queryKey: classroomKeys.byCode(code ?? ''),
queryFn: () => (code ? fetchClassroomByCode(code) : null),
enabled: !!code && code.length >= 4, // Only query if code is entered
staleTime: 30_000, // 30 seconds
})
}
/**
* Check if current user is a teacher (has a classroom)
*/
export function useIsTeacher() {
const { data: classroom, isLoading } = useMyClassroom()
return {
isTeacher: classroom !== null && classroom !== undefined,
isTeacher: classroom !== null,
isLoading,
classroom,
}
}

View File

@ -34,3 +34,13 @@ export const sessionHistoryKeys = {
all: ['sessionHistory'] as const,
list: (playerId: string) => [...sessionHistoryKeys.all, playerId] as const,
}
// Classroom query keys
export const classroomKeys = {
all: ['classrooms'] as const,
mine: () => [...classroomKeys.all, 'mine'] as const,
byCode: (code: string) => [...classroomKeys.all, 'byCode', code] as const,
detail: (id: string) => [...classroomKeys.all, 'detail', id] as const,
enrollments: (id: string) => [...classroomKeys.all, 'enrollments', id] as const,
presence: (id: string) => [...classroomKeys.all, 'presence', id] as const,
}