feat(classroom): add unified add-student modal with two-column layout
Consolidates three add-student flows into a single discoverable modal: - Left column "Add Now": Create student button + family code input - Right column "Invite Parents": QR code + copy code/link buttons Also refactors TeacherClassroomCard: - Moves classroom name from header to first filter segment - Removes ShareCodePanel from header (now in unified modal) - Adds classroomName prop to TeacherCompoundChip for label override 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import {
|
||||
AddStudentByFamilyCodeModal,
|
||||
AddStudentToClassroomModal,
|
||||
CreateClassroomForm,
|
||||
PendingApprovalsSection,
|
||||
SessionObserverModal,
|
||||
@@ -114,7 +115,10 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
// Track if we're adding to classroom (auto-enroll mode)
|
||||
const [addToClassroomMode, setAddToClassroomMode] = useState(false)
|
||||
|
||||
// Add student modal state (teacher mode - add by family code)
|
||||
// Unified add student to classroom modal (teacher mode - combines create, share, family code)
|
||||
const [showUnifiedAddModal, setShowUnifiedAddModal] = useState(false)
|
||||
|
||||
// Add student modal state (teacher mode - add by family code) - legacy, kept for direct access
|
||||
const [showAddByFamilyCode, setShowAddByFamilyCode] = useState(false)
|
||||
|
||||
// Session observation state
|
||||
@@ -302,8 +306,13 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle add student to classroom - show create modal with auto-enroll
|
||||
// Handle add student to classroom - show unified modal with all options
|
||||
const handleAddStudentToClassroom = useCallback(() => {
|
||||
setShowUnifiedAddModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle create student from unified modal - opens create modal with auto-enroll
|
||||
const handleCreateStudentFromUnified = useCallback(() => {
|
||||
setAddToClassroomMode(true)
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
@@ -709,7 +718,19 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
classroomName={addToClassroomMode ? classroom?.name : undefined}
|
||||
/>
|
||||
|
||||
{/* Add Student Modal (Teacher - add by family code) */}
|
||||
{/* Unified Add Student to Classroom Modal (Teacher mode - combines all options) */}
|
||||
{classroomId && classroomCode && classroom && (
|
||||
<AddStudentToClassroomModal
|
||||
isOpen={showUnifiedAddModal}
|
||||
onClose={() => setShowUnifiedAddModal(false)}
|
||||
classroomId={classroomId}
|
||||
classroomName={classroom.name}
|
||||
classroomCode={classroomCode}
|
||||
onCreateStudent={handleCreateStudentFromUnified}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Student Modal (Teacher - add by family code) - legacy, kept for direct access */}
|
||||
{classroomId && (
|
||||
<AddStudentByFamilyCodeModal
|
||||
isOpen={showAddByFamilyCode}
|
||||
|
||||
748
apps/web/src/components/classroom/AddStudentToClassroomModal.tsx
Normal file
748
apps/web/src/components/classroom/AddStudentToClassroomModal.tsx
Normal file
@@ -0,0 +1,748 @@
|
||||
'use client'
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { AbacusQRCode } from '@/components/common/AbacusQRCode'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useShareCode } from '@/hooks/useShareCode'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface PlayerPreview {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface AddStudentToClassroomModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
classroomId: string
|
||||
classroomName: string
|
||||
classroomCode: string
|
||||
/** Called when user wants to create a new student (opens the create student modal) */
|
||||
onCreateStudent: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified modal for adding students to a classroom.
|
||||
*
|
||||
* Two columns:
|
||||
* - Left: "ADD NOW" - Create student button + Enter family code
|
||||
* - Right: "INVITE PARENTS" - Share classroom code with QR
|
||||
*
|
||||
* This consolidates multiple add-student flows into a single discoverable UI.
|
||||
*/
|
||||
export function AddStudentToClassroomModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
classroomId,
|
||||
classroomName,
|
||||
classroomCode,
|
||||
onCreateStudent,
|
||||
}: AddStudentToClassroomModalProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Family code input state
|
||||
const [familyCode, setFamilyCode] = useState('')
|
||||
const [isSubmittingFamilyCode, setIsSubmittingFamilyCode] = useState(false)
|
||||
const [familyCodeError, setFamilyCodeError] = useState<string | null>(null)
|
||||
const [familyCodeSuccess, setFamilyCodeSuccess] = useState(false)
|
||||
const [enrolledPlayer, setEnrolledPlayer] = useState<PlayerPreview | null>(null)
|
||||
|
||||
// Share code hook for the classroom
|
||||
const shareCode = useShareCode({ type: 'classroom', code: classroomCode })
|
||||
|
||||
const handleFamilyCodeSubmit = useCallback(async () => {
|
||||
if (!familyCode.trim()) {
|
||||
setFamilyCodeError('Please enter a family code')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmittingFamilyCode(true)
|
||||
setFamilyCodeError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/classrooms/${classroomId}/enroll-by-family-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ familyCode: familyCode.trim() }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
setFamilyCodeError(data.error || 'Failed to add student')
|
||||
return
|
||||
}
|
||||
|
||||
setEnrolledPlayer(data.player)
|
||||
setFamilyCodeSuccess(true)
|
||||
} catch (_err) {
|
||||
setFamilyCodeError('Failed to add student. Please try again.')
|
||||
} finally {
|
||||
setIsSubmittingFamilyCode(false)
|
||||
}
|
||||
}, [familyCode, classroomId])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Reset state
|
||||
setFamilyCode('')
|
||||
setFamilyCodeError(null)
|
||||
setFamilyCodeSuccess(false)
|
||||
setEnrolledPlayer(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCreateStudent = useCallback(() => {
|
||||
handleClose()
|
||||
onCreateStudent()
|
||||
}, [handleClose, onCreateStudent])
|
||||
|
||||
const handleAddAnother = useCallback(() => {
|
||||
setFamilyCode('')
|
||||
setFamilyCodeError(null)
|
||||
setFamilyCodeSuccess(false)
|
||||
setEnrolledPlayer(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
})}
|
||||
/>
|
||||
<Dialog.Content
|
||||
data-component="add-student-to-classroom-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
width: 'calc(100% - 2rem)',
|
||||
maxWidth: { base: '400px', sm: '700px' },
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 50px -12px rgba(0, 0, 0, 0.4)',
|
||||
zIndex: Z_INDEX.MODAL + 1,
|
||||
outline: 'none',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
data-element="modal-header"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
Add Student to {classroomName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-modal"
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.25rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
},
|
||||
})}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Two-column content */}
|
||||
<div
|
||||
data-element="modal-content"
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', sm: '1fr 1fr' },
|
||||
gap: '0',
|
||||
})}
|
||||
>
|
||||
{/* Left column: ADD NOW */}
|
||||
<div
|
||||
data-section="add-now"
|
||||
className={css({
|
||||
padding: '20px',
|
||||
borderRight: { base: 'none', sm: '1px solid' },
|
||||
borderBottom: { base: '1px solid', sm: 'none' },
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Add Now
|
||||
</h3>
|
||||
|
||||
{/* Create Student Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateStudent}
|
||||
data-action="create-student"
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
marginBottom: '12px',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.600' : 'green.600',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.25rem' })}>+</span>
|
||||
Create Student
|
||||
</button>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
textAlign: 'center',
|
||||
marginBottom: '20px',
|
||||
lineHeight: '1.4',
|
||||
})}
|
||||
>
|
||||
Quick setup - student doesn't need an existing account
|
||||
</p>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
})}
|
||||
>
|
||||
or
|
||||
</span>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Family Code Section */}
|
||||
{familyCodeSuccess && enrolledPlayer ? (
|
||||
<FamilyCodeSuccess
|
||||
player={enrolledPlayer}
|
||||
isDark={isDark}
|
||||
onAddAnother={handleAddAnother}
|
||||
onDone={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<FamilyCodeInput
|
||||
value={familyCode}
|
||||
onChange={(val) => {
|
||||
setFamilyCode(val)
|
||||
setFamilyCodeError(null)
|
||||
}}
|
||||
onSubmit={handleFamilyCodeSubmit}
|
||||
isSubmitting={isSubmittingFamilyCode}
|
||||
error={familyCodeError}
|
||||
isDark={isDark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: INVITE PARENTS */}
|
||||
<div
|
||||
data-section="invite-parents"
|
||||
className={css({
|
||||
padding: '20px',
|
||||
backgroundColor: isDark ? 'gray.750' : 'gray.50',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Invite Parents
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
marginBottom: '16px',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
Share this code with parents. They'll enter it to request enrollment, and you'll
|
||||
approve each request.
|
||||
</p>
|
||||
|
||||
{/* QR Code */}
|
||||
<div
|
||||
data-element="qr-code-container"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '16px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
<AbacusQRCode value={shareCode.shareUrl} size={160} />
|
||||
</div>
|
||||
|
||||
{/* Code display + copy buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{/* Code button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareCode.copyCode}
|
||||
data-action="copy-code"
|
||||
data-status={shareCode.codeCopied ? 'copied' : 'idle'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: shareCode.codeCopied
|
||||
? isDark
|
||||
? 'green.900/60'
|
||||
: 'green.50'
|
||||
: isDark
|
||||
? 'purple.900/60'
|
||||
: 'purple.50',
|
||||
border: '2px solid',
|
||||
borderColor: shareCode.codeCopied
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.300'
|
||||
: isDark
|
||||
? 'purple.700'
|
||||
: 'purple.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
backgroundColor: shareCode.codeCopied
|
||||
? isDark
|
||||
? 'green.800/60'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'purple.800/60'
|
||||
: 'purple.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '0.1em',
|
||||
color: shareCode.codeCopied
|
||||
? isDark
|
||||
? 'green.300'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'purple.300'
|
||||
: 'purple.700',
|
||||
})}
|
||||
>
|
||||
{shareCode.codeCopied ? '✓ Copied!' : classroomCode}
|
||||
</span>
|
||||
{!shareCode.codeCopied && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'purple.400' : 'purple.500',
|
||||
})}
|
||||
>
|
||||
Copy
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Link button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareCode.copyLink}
|
||||
data-action="copy-link"
|
||||
data-status={shareCode.linkCopied ? 'copied' : 'idle'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: shareCode.linkCopied
|
||||
? isDark
|
||||
? 'green.900/60'
|
||||
: 'green.50'
|
||||
: isDark
|
||||
? 'blue.900/60'
|
||||
: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: shareCode.linkCopied
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.300'
|
||||
: isDark
|
||||
? 'blue.700'
|
||||
: 'blue.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
backgroundColor: shareCode.linkCopied
|
||||
? isDark
|
||||
? 'green.800/60'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'blue.800/60'
|
||||
: 'blue.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: shareCode.linkCopied
|
||||
? isDark
|
||||
? 'green.300'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'blue.300'
|
||||
: 'blue.700',
|
||||
})}
|
||||
>
|
||||
{shareCode.linkCopied ? '✓ Link copied!' : '🔗 Copy link to share'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
interface FamilyCodeInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
error: string | null
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function FamilyCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
error,
|
||||
isDark,
|
||||
}: FamilyCodeInputProps) {
|
||||
return (
|
||||
<div data-element="family-code-section">
|
||||
<label
|
||||
htmlFor="family-code-input"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Have a parent's family code?
|
||||
</label>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
marginBottom: '12px',
|
||||
lineHeight: '1.4',
|
||||
})}
|
||||
>
|
||||
Enter their code to request enrollment. The parent will need to approve.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: error ? '8px' : '0',
|
||||
})}
|
||||
>
|
||||
<input
|
||||
id="family-code-input"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && value.trim() && !isSubmitting) {
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., ABCD-1234"
|
||||
data-element="family-code-input"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
fontSize: '1rem',
|
||||
fontFamily: 'monospace',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.08em',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
border: '2px solid',
|
||||
borderColor: error
|
||||
? isDark
|
||||
? 'red.500'
|
||||
: 'red.400'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
outline: 'none',
|
||||
_focus: {
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !value.trim()}
|
||||
data-action="submit-family-code"
|
||||
className={css({
|
||||
padding: '10px 16px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'blue.600' : 'blue.600',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
data-element="error-message"
|
||||
className={css({
|
||||
padding: '10px 12px',
|
||||
backgroundColor: isDark ? 'red.900/30' : 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.700' : 'red.200',
|
||||
borderRadius: '8px',
|
||||
color: isDark ? 'red.300' : 'red.700',
|
||||
fontSize: '0.8125rem',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FamilyCodeSuccessProps {
|
||||
player: PlayerPreview
|
||||
isDark: boolean
|
||||
onAddAnother: () => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
function FamilyCodeSuccess({ player, isDark, onAddAnother, onDone }: FamilyCodeSuccessProps) {
|
||||
return (
|
||||
<div data-element="family-code-success" className={css({ textAlign: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.75rem',
|
||||
margin: '0 auto 12px',
|
||||
})}
|
||||
style={{ backgroundColor: player.color }}
|
||||
>
|
||||
{player.emoji}
|
||||
</div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Request Sent!
|
||||
</h4>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '16px',
|
||||
lineHeight: '1.4',
|
||||
})}
|
||||
>
|
||||
<strong>{player.name}</strong> will be added once their parent approves.
|
||||
</p>
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddAnother}
|
||||
data-action="add-another"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Add Another
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
data-action="done"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.600' : 'green.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal'
|
||||
export { AddStudentToClassroomModal } from './AddStudentToClassroomModal'
|
||||
export { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
export { CreateClassroomForm } from './CreateClassroomForm'
|
||||
export { EnrollChildFlow } from './EnrollChildFlow'
|
||||
|
||||
@@ -376,20 +376,20 @@ function CompactShareChip({
|
||||
css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
bg: isDark ? 'gray.700' : 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
gap: '3px',
|
||||
padding: '2px 6px',
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
fontWeight: '500',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.200',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.400',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.98)',
|
||||
@@ -397,7 +397,7 @@ function CompactShareChip({
|
||||
}) + (className ? ` ${className}` : '')
|
||||
}
|
||||
>
|
||||
<span>📋</span>
|
||||
<span className={css({ fontSize: '10px' })}>📋</span>
|
||||
<span>{code}</span>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -684,27 +684,16 @@ export function TeacherClassroomCard({
|
||||
}: TeacherClassroomCardProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const shareCode = useShareCode({ type: 'classroom', code: classroom.code })
|
||||
const updateClassroom = useUpdateClassroom()
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameValue, setNameValue] = useState(classroom.name)
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset name value when classroom changes or popover opens
|
||||
// Reset name value when classroom changes or settings popover opens
|
||||
useEffect(() => {
|
||||
setNameValue(classroom.name)
|
||||
setEditingName(false)
|
||||
}, [classroom.name, isSettingsOpen])
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (editingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus()
|
||||
nameInputRef.current.select()
|
||||
}
|
||||
}, [editingName])
|
||||
|
||||
const handleNameSave = useCallback(() => {
|
||||
const trimmedName = nameValue.trim()
|
||||
if (trimmedName && trimmedName !== classroom.name) {
|
||||
@@ -713,7 +702,6 @@ export function TeacherClassroomCard({
|
||||
name: trimmedName,
|
||||
})
|
||||
}
|
||||
setEditingName(false)
|
||||
}, [classroom.id, classroom.name, nameValue, updateClassroom])
|
||||
|
||||
const handleExpiryChange = useCallback(
|
||||
@@ -734,7 +722,7 @@ export function TeacherClassroomCard({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
bg: isDark ? 'gray.750' : 'gray.50',
|
||||
@@ -742,102 +730,30 @@ export function TeacherClassroomCard({
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Header row: Classroom name + actions */}
|
||||
{/* Header row: Action buttons only - classroom name is now in first segment */}
|
||||
<div
|
||||
data-element="classroom-card-header"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Classroom name - editable */}
|
||||
<div
|
||||
data-element="classroom-name"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '14px', flexShrink: 0 })}>📚</span>
|
||||
{editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
setNameValue(classroom.name)
|
||||
setEditingName(false)
|
||||
}
|
||||
}}
|
||||
onBlur={handleNameSave}
|
||||
disabled={updateClassroom.isPending}
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: '100px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.500',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
outline: 'none',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingName(true)}
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
title="Click to edit classroom name"
|
||||
>
|
||||
{classroom.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{/* Action buttons - minimal icons */}
|
||||
<div
|
||||
data-element="classroom-actions"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
gap: '2px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Share code chip */}
|
||||
<ShareCodePanel shareCode={shareCode} compact showRegenerate={false} />
|
||||
|
||||
{/* Add student button - auto-enrolls in classroom */}
|
||||
{/* Add student button - opens unified modal with share code, create, family code */}
|
||||
{onAddStudentToClassroom && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -848,19 +764,17 @@ export function TeacherClassroomCard({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.300',
|
||||
backgroundColor: isDark ? 'green.900' : 'green.50',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
fontSize: '16px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.800' : 'green.100',
|
||||
borderColor: isDark ? 'green.600' : 'green.400',
|
||||
backgroundColor: isDark ? 'green.900' : 'green.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -868,7 +782,7 @@ export function TeacherClassroomCard({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings button with popover */}
|
||||
{/* Settings button with popover - icon only */}
|
||||
<Popover.Root open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
@@ -878,19 +792,17 @@ export function TeacherClassroomCard({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.100',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.400',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
aria-label="Classroom settings"
|
||||
@@ -928,6 +840,52 @@ export function TeacherClassroomCard({
|
||||
Classroom Settings
|
||||
</h3>
|
||||
|
||||
{/* Classroom name setting */}
|
||||
<div data-setting="classroom-name" className={css({ marginBottom: '12px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Classroom name
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
setNameValue(classroom.name)
|
||||
}
|
||||
}}
|
||||
onBlur={handleNameSave}
|
||||
disabled={updateClassroom.isPending}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: '13px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
cursor: updateClassroom.isPending ? 'wait' : 'text',
|
||||
opacity: updateClassroom.isPending ? 0.7 : 1,
|
||||
_focus: {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'blue.500',
|
||||
outlineOffset: '1px',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entry prompt expiry setting */}
|
||||
<div data-setting="entry-prompt-expiry">
|
||||
<label
|
||||
@@ -994,21 +952,15 @@ export function TeacherClassroomCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter row: Embedded compound chip */}
|
||||
<div
|
||||
data-element="classroom-card-filters"
|
||||
className={css({
|
||||
padding: '6px 8px',
|
||||
})}
|
||||
>
|
||||
<TeacherCompoundChip
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
viewCounts={viewCounts}
|
||||
availableViews={availableViews}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
{/* Filter row: Embedded compound chip - flush with card edges */}
|
||||
<TeacherCompoundChip
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
viewCounts={viewCounts}
|
||||
availableViews={availableViews}
|
||||
embedded
|
||||
classroomName={classroom.name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -533,6 +533,8 @@ export interface TeacherCompoundChipProps {
|
||||
isDark?: boolean
|
||||
/** When true, removes outer border/radius (for embedding in a card) */
|
||||
embedded?: boolean
|
||||
/** Optional classroom name to display instead of "Enrolled" in first segment */
|
||||
classroomName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,6 +552,7 @@ export function TeacherCompoundChip({
|
||||
availableViews,
|
||||
isDark: isDarkProp,
|
||||
embedded = false,
|
||||
classroomName,
|
||||
}: TeacherCompoundChipProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = isDarkProp ?? resolvedTheme === 'dark'
|
||||
@@ -573,11 +576,14 @@ export function TeacherCompoundChip({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid',
|
||||
// When embedded, no border/radius - blends into parent card
|
||||
borderRadius: embedded ? '0' : '16px',
|
||||
border: embedded ? 'none' : '1px solid',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
// When embedded, fill width
|
||||
width: embedded ? '100%' : 'auto',
|
||||
borderColor: isAnyActive
|
||||
? isDark
|
||||
? 'blue.600'
|
||||
@@ -587,7 +593,7 @@ export function TeacherCompoundChip({
|
||||
: 'gray.300',
|
||||
})}
|
||||
>
|
||||
{/* Enrolled segment */}
|
||||
{/* Enrolled segment - shows classroom name if provided */}
|
||||
<ChipSegment
|
||||
config={enrolledConfig}
|
||||
isActive={isEnrolledActive}
|
||||
@@ -597,6 +603,8 @@ export function TeacherCompoundChip({
|
||||
position="first"
|
||||
isCompoundActive={isAnyActive}
|
||||
colorScheme="blue"
|
||||
embedded={embedded}
|
||||
labelOverride={classroomName}
|
||||
/>
|
||||
|
||||
{/* In Classroom segment */}
|
||||
@@ -609,6 +617,7 @@ export function TeacherCompoundChip({
|
||||
position={hasActiveSegment ? 'middle' : 'last'}
|
||||
isCompoundActive={isAnyActive}
|
||||
colorScheme="blue"
|
||||
embedded={embedded}
|
||||
/>
|
||||
|
||||
{/* Active segment - only show if there are active sessions */}
|
||||
@@ -622,6 +631,7 @@ export function TeacherCompoundChip({
|
||||
position="last"
|
||||
isCompoundActive={isAnyActive}
|
||||
colorScheme="green"
|
||||
embedded={embedded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -637,6 +647,10 @@ interface ChipSegmentProps {
|
||||
position: 'first' | 'middle' | 'last'
|
||||
isCompoundActive: boolean
|
||||
colorScheme: 'blue' | 'green'
|
||||
/** When embedded in a card, segments flex evenly */
|
||||
embedded?: boolean
|
||||
/** Optional label override (e.g., classroom name instead of "Enrolled") */
|
||||
labelOverride?: string
|
||||
}
|
||||
|
||||
function ChipSegment({
|
||||
@@ -648,6 +662,8 @@ function ChipSegment({
|
||||
position,
|
||||
isCompoundActive,
|
||||
colorScheme,
|
||||
embedded = false,
|
||||
labelOverride,
|
||||
}: ChipSegmentProps) {
|
||||
const isLast = position === 'last'
|
||||
|
||||
@@ -661,14 +677,17 @@ function ChipSegment({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
justifyContent: embedded ? 'center' : 'flex-start',
|
||||
gap: embedded ? '4px' : '6px',
|
||||
padding: embedded ? '4px 8px' : '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: embedded ? '11px' : '13px',
|
||||
fontWeight: 'medium',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 'none',
|
||||
// When embedded, segments share space equally
|
||||
flex: embedded ? 1 : 'none',
|
||||
borderRight: isLast ? 'none' : '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
bg: isActive
|
||||
@@ -708,7 +727,7 @@ function ChipSegment({
|
||||
})}
|
||||
>
|
||||
<span>{config.icon}</span>
|
||||
<span>{config.shortLabel ?? config.label}</span>
|
||||
<span>{labelOverride ?? config.shortLabel ?? config.label}</span>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
data-element="segment-count"
|
||||
|
||||
Reference in New Issue
Block a user