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:
parent
02842270c9
commit
2202716f56
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
export { ClassroomDashboard } from './ClassroomDashboard'
|
||||
export { ClassroomTab } from './ClassroomTab'
|
||||
export { CreateClassroomForm } from './CreateClassroomForm'
|
||||
export { StudentManagerTab } from './StudentManagerTab'
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue