feat(practice): add fixed filter bar, sticky headers, and shared EmojiPicker
- Make StudentFilterBar fixed below nav with proper z-index layering - Add sticky bucket headers (Today, This Week, etc.) and category headers - Move bulk actions into filter bar (shown in edit mode in place of search) - Create shared EmojiPicker component with emojibase-data integration - Simplify AddStudentModal to use shared EmojiPicker (single way to pick emoji) - Add z-index constants for filter bar and sticky headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
17
apps/web/src/app/api/players/with-skill-data/route.ts
Normal file
17
apps/web/src/app/api/players/with-skill-data/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPlayersWithSkillData } from '@/lib/curriculum/server'
|
||||
|
||||
/**
|
||||
* GET /api/players/with-skill-data
|
||||
* Returns all players for the current viewer with their skill data
|
||||
* (practicing skills, last practiced, skill category)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const players = await getPlayersWithSkillData()
|
||||
return NextResponse.json({ players })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch players with skill data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
398
apps/web/src/app/practice/AddStudentModal.tsx
Normal file
398
apps/web/src/app/practice/AddStudentModal.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { EmojiPicker } from '@/components/EmojiPicker'
|
||||
import { PLAYER_EMOJIS } from '@/constants/playerEmojis'
|
||||
import { useCreatePlayer } from '@/hooks/useUserPlayers'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
// Available colors for student avatars
|
||||
const AVAILABLE_COLORS = [
|
||||
'#FFB3BA', // light pink
|
||||
'#FFDFBA', // light orange
|
||||
'#FFFFBA', // light yellow
|
||||
'#BAFFC9', // light green
|
||||
'#BAE1FF', // light blue
|
||||
'#DCC6E0', // light purple
|
||||
'#F0E68C', // khaki
|
||||
'#98D8C8', // mint
|
||||
'#F7DC6F', // gold
|
||||
'#BB8FCE', // orchid
|
||||
'#85C1E9', // sky blue
|
||||
'#F8B500', // amber
|
||||
]
|
||||
|
||||
interface AddStudentModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding a new student
|
||||
* Uses React Query mutation for proper cache management
|
||||
*/
|
||||
export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProps) {
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formEmoji, setFormEmoji] = useState(PLAYER_EMOJIS[0])
|
||||
const [formColor, setFormColor] = useState(AVAILABLE_COLORS[0])
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
|
||||
// React Query mutation
|
||||
const createPlayer = useCreatePlayer()
|
||||
|
||||
// Reset form and pick random emoji/color when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormName('')
|
||||
setFormEmoji(PLAYER_EMOJIS[Math.floor(Math.random() * PLAYER_EMOJIS.length)])
|
||||
setFormColor(AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)])
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!formName.trim()) return
|
||||
|
||||
createPlayer.mutate(
|
||||
{
|
||||
name: formName.trim(),
|
||||
emoji: formEmoji,
|
||||
color: formColor,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [formName, formEmoji, formColor, createPlayer, onClose])
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showEmojiPicker) {
|
||||
setShowEmojiPicker(false)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
} else if (
|
||||
e.key === 'Enter' &&
|
||||
formName.trim() &&
|
||||
!createPlayer.isPending &&
|
||||
!showEmojiPicker
|
||||
) {
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[formName, createPlayer.isPending, handleSubmit, onClose, showEmojiPicker]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Show the shared EmojiPicker as a full-screen overlay when picking emoji
|
||||
if (showEmojiPicker) {
|
||||
return (
|
||||
<EmojiPicker
|
||||
currentEmoji={formEmoji}
|
||||
onEmojiSelect={(emoji) => {
|
||||
setFormEmoji(emoji)
|
||||
setShowEmojiPicker(false)
|
||||
}}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
title="Choose Avatar"
|
||||
accentColor="green"
|
||||
isDark={isDark}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="add-student-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-student-title"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1000,
|
||||
padding: '1rem',
|
||||
})}
|
||||
>
|
||||
{/* Backdrop click to close */}
|
||||
<button
|
||||
type="button"
|
||||
data-element="modal-backdrop"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div
|
||||
data-element="modal-content"
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
boxShadow: 'lg',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
id="add-student-title"
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
Add New Student
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-modal"
|
||||
onClick={onClose}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Avatar Preview - clickable to open full picker */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmojiPicker(true)}
|
||||
data-element="avatar-preview"
|
||||
className={css({
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2.5rem',
|
||||
boxShadow: 'md',
|
||||
border: '3px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
_hover: {
|
||||
borderColor: 'blue.500',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
})}
|
||||
style={{ backgroundColor: formColor }}
|
||||
title="Click to choose avatar"
|
||||
>
|
||||
{formEmoji}
|
||||
</button>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
Tap to change avatar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name input */}
|
||||
<div className={css({ marginBottom: '1.25rem' })}>
|
||||
<label
|
||||
htmlFor="new-student-name"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="new-student-name"
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Enter student name"
|
||||
// biome-ignore lint/a11y/noAutofocus: Modal just opened, focusing input is expected UX
|
||||
autoFocus
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color selector */}
|
||||
<div className={css({ marginBottom: '1.5rem' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setFormColor(color)}
|
||||
className={css({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid',
|
||||
borderColor: formColor === color ? 'blue.500' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
_hover: {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
})}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
onClick={onClose}
|
||||
disabled={createPlayer.isPending}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="add-student"
|
||||
onClick={handleSubmit}
|
||||
disabled={createPlayer.isPending || !formName.trim()}
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: createPlayer.isPending ? 'gray.400' : 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: createPlayer.isPending ? 'not-allowed' : 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: createPlayer.isPending ? 'gray.400' : 'green.600',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{createPlayer.isPending ? 'Adding...' : 'Add Student'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
|
||||
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { usePlayersWithSkillData, useUpdatePlayer } from '@/hooks/useUserPlayers'
|
||||
import type { StudentWithSkillData } from '@/utils/studentGrouping'
|
||||
import { filterStudents, groupStudents } from '@/utils/studentGrouping'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { AddStudentModal } from './AddStudentModal'
|
||||
|
||||
interface PracticeClientProps {
|
||||
initialPlayers: StudentWithSkillData[]
|
||||
@@ -17,7 +20,7 @@ interface PracticeClientProps {
|
||||
/**
|
||||
* Practice page client component
|
||||
*
|
||||
* Receives prefetched player data with skill information from the server component.
|
||||
* Uses React Query with server-prefetched data for immediate rendering.
|
||||
* Manages filter state (search, skills, archived, edit mode) and passes
|
||||
* grouped/filtered students to StudentSelector.
|
||||
*/
|
||||
@@ -33,8 +36,16 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Use initial data from server (local state for optimistic updates)
|
||||
const [players, setPlayers] = useState(initialPlayers)
|
||||
// Add student modal state
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
// Use React Query with initial data from server for instant render + live updates
|
||||
const { data: players = initialPlayers } = usePlayersWithSkillData({
|
||||
initialData: initialPlayers,
|
||||
})
|
||||
|
||||
// Mutation for bulk updates
|
||||
const updatePlayer = useUpdatePlayer()
|
||||
|
||||
// Count archived students
|
||||
const archivedCount = useMemo(() => players.filter((p) => p.isArchived).length, [players])
|
||||
@@ -47,26 +58,6 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
|
||||
const groupedStudents = useMemo(() => groupStudents(filteredStudents), [filteredStudents])
|
||||
|
||||
// Convert to StudentWithProgress format for StudentSelector
|
||||
// (maintaining backwards compatibility)
|
||||
const students: StudentWithProgress[] = useMemo(
|
||||
() =>
|
||||
filteredStudents.map((player) => ({
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
createdAt: player.createdAt,
|
||||
notes: player.notes,
|
||||
isArchived: player.isArchived,
|
||||
// Pass through skill data for grouping display
|
||||
practicingSkills: player.practicingSkills,
|
||||
lastPracticedAt: player.lastPracticedAt,
|
||||
skillCategory: player.skillCategory,
|
||||
})),
|
||||
[filteredStudents]
|
||||
)
|
||||
|
||||
// Handle student selection - navigate to student's resume page
|
||||
const handleSelectStudent = useCallback(
|
||||
(student: StudentWithProgress) => {
|
||||
@@ -88,19 +79,15 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
[router, editMode]
|
||||
)
|
||||
|
||||
// Handle bulk archive
|
||||
// Handle bulk archive using React Query mutation
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
||||
// Optimistically update local state
|
||||
setPlayers((prev) => prev.map((p) => (selectedIds.has(p.id) ? { ...p, isArchived: true } : p)))
|
||||
|
||||
// Send requests to archive each selected student
|
||||
// Send requests to archive each selected student using mutations
|
||||
const promises = Array.from(selectedIds).map((id) =>
|
||||
fetch(`/api/players/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isArchived: true }),
|
||||
updatePlayer.mutateAsync({
|
||||
id,
|
||||
updates: { isArchived: true },
|
||||
})
|
||||
)
|
||||
|
||||
@@ -109,7 +96,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
// Clear selection and exit edit mode
|
||||
setSelectedIds(new Set())
|
||||
setEditMode(false)
|
||||
}, [selectedIds])
|
||||
}, [selectedIds, updatePlayer])
|
||||
|
||||
// Handle edit mode change
|
||||
const handleEditModeChange = useCallback((editing: boolean) => {
|
||||
@@ -119,6 +106,22 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle add student
|
||||
const handleAddStudent = useCallback(() => {
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseAddModal = useCallback(() => {
|
||||
setShowAddModal(false)
|
||||
}, [])
|
||||
|
||||
// Count archived students that match filters but are hidden
|
||||
const hiddenArchivedCount = useMemo(() => {
|
||||
if (showArchived) return 0
|
||||
return filterStudents(players, searchQuery, skillFilters, true).filter((p) => p.isArchived)
|
||||
.length
|
||||
}, [players, searchQuery, skillFilters, showArchived])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
@@ -126,7 +129,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: '80px', // Nav height
|
||||
paddingTop: '160px', // Nav height (80px) + Filter bar height (~80px)
|
||||
})}
|
||||
>
|
||||
{/* Filter Bar */}
|
||||
@@ -140,53 +143,11 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
editMode={editMode}
|
||||
onEditModeChange={handleEditModeChange}
|
||||
archivedCount={archivedCount}
|
||||
onAddStudent={handleAddStudent}
|
||||
selectedCount={selectedIds.size}
|
||||
onBulkArchive={handleBulkArchive}
|
||||
/>
|
||||
|
||||
{/* Edit mode bulk actions */}
|
||||
{editMode && selectedIds.size > 0 && (
|
||||
<div
|
||||
data-element="bulk-actions"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
bg: isDark ? 'amber.900/50' : 'amber.50',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'amber.800' : 'amber.200',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: isDark ? 'amber.200' : 'amber.700',
|
||||
})}
|
||||
>
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkArchive}
|
||||
data-action="bulk-archive"
|
||||
className={css({
|
||||
padding: '6px 12px',
|
||||
bg: isDark ? 'red.900' : 'red.100',
|
||||
color: isDark ? 'red.200' : 'red.700',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.700' : 'red.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isDark ? 'red.800' : 'red.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Archive Selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '1000px',
|
||||
@@ -230,11 +191,42 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
{searchQuery || skillFilters.length > 0
|
||||
? 'No students match your filters'
|
||||
: showArchived
|
||||
? 'No archived students'
|
||||
: 'No students yet. Add students from the Manage Students page.'}
|
||||
{searchQuery || skillFilters.length > 0 ? (
|
||||
'No students match your filters'
|
||||
) : showArchived ? (
|
||||
'No archived students'
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
<span>No students yet.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddStudent}
|
||||
data-action="add-first-student"
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
bg: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isDark ? 'green.600' : 'green.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+ Add Your First Student
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -247,18 +239,24 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
>
|
||||
{groupedStudents.map((bucket) => (
|
||||
<div key={bucket.bucket} data-bucket={bucket.bucket}>
|
||||
{/* Bucket header */}
|
||||
{/* Bucket header - sticky below filter bar */}
|
||||
<h2
|
||||
data-element="bucket-header"
|
||||
className={css({
|
||||
position: 'sticky',
|
||||
top: '160px', // Nav (80px) + Filter bar (~80px)
|
||||
zIndex: Z_INDEX.STICKY_BUCKET_HEADER,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '12px',
|
||||
paddingTop: '8px',
|
||||
paddingBottom: '8px',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
bg: isDark ? 'gray.900' : 'gray.50',
|
||||
})}
|
||||
>
|
||||
{bucket.bucketName}
|
||||
@@ -277,14 +275,21 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
key={category.category ?? 'null'}
|
||||
data-category={category.category ?? 'new'}
|
||||
>
|
||||
{/* Category header */}
|
||||
{/* Category header - sticky below bucket header */}
|
||||
<h3
|
||||
data-element="category-header"
|
||||
className={css({
|
||||
position: 'sticky',
|
||||
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
|
||||
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
marginBottom: '8px',
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '4px',
|
||||
bg: isDark ? 'gray.900' : 'gray.50',
|
||||
})}
|
||||
>
|
||||
{category.categoryName}
|
||||
@@ -297,6 +302,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
title=""
|
||||
editMode={editMode}
|
||||
selectedIds={selectedIds}
|
||||
hideAddButton
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -305,8 +311,56 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden archived students indicator - only shown when filtering */}
|
||||
{hiddenArchivedCount > 0 && !showArchived && (searchQuery || skillFilters.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
data-element="hidden-archived-indicator"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
marginTop: '16px',
|
||||
bg: isDark ? 'gray.800/50' : 'gray.100',
|
||||
border: '1px dashed',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.300',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.800' : 'gray.200',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>👁🗨</span>
|
||||
<span>
|
||||
{hiddenArchivedCount} archived student{hiddenArchivedCount !== 1 ? 's' : ''} not
|
||||
shown
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
— click to show
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Add Student Modal */}
|
||||
<AddStudentModal isOpen={showAddModal} onClose={handleCloseAddModal} isDark={isDark} />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
769
apps/web/src/components/EmojiPicker.tsx
Normal file
769
apps/web/src/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
'use client'
|
||||
|
||||
import emojiData from 'emojibase-data/en/data.json'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { PLAYER_EMOJIS } from '@/constants/playerEmojis'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
// Proper TypeScript interface for emojibase-data structure
|
||||
interface EmojibaseEmoji {
|
||||
label: string
|
||||
hexcode: string
|
||||
tags?: string[]
|
||||
emoji: string
|
||||
text: string
|
||||
type: number
|
||||
order: number
|
||||
group: number
|
||||
subgroup: number
|
||||
version: number
|
||||
emoticon?: string | string[]
|
||||
}
|
||||
|
||||
export interface EmojiPickerProps {
|
||||
currentEmoji: string
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
/** Title shown in the header */
|
||||
title?: string
|
||||
/** Accent color for selected state */
|
||||
accentColor?: 'blue' | 'pink' | 'purple' | 'yellow' | 'green'
|
||||
/** Whether to use dark mode styling */
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
// Emoji group categories from emojibase (matching Unicode CLDR group IDs)
|
||||
const EMOJI_GROUPS = {
|
||||
0: { name: 'Smileys & Emotion', icon: '😀' },
|
||||
1: { name: 'People & Body', icon: '👤' },
|
||||
3: { name: 'Animals & Nature', icon: '🐶' },
|
||||
4: { name: 'Food & Drink', icon: '🍎' },
|
||||
5: { name: 'Travel & Places', icon: '🚗' },
|
||||
6: { name: 'Activities', icon: '⚽' },
|
||||
7: { name: 'Objects', icon: '💡' },
|
||||
8: { name: 'Symbols', icon: '❤️' },
|
||||
9: { name: 'Flags', icon: '🏁' },
|
||||
} as const
|
||||
|
||||
// Create a map of emoji to their searchable data and group
|
||||
const emojiMap = new Map<string, { keywords: string[]; group: number }>()
|
||||
;(emojiData as EmojibaseEmoji[]).forEach((emoji) => {
|
||||
if (emoji.emoji) {
|
||||
const emoticons: string[] = []
|
||||
if (emoji.emoticon) {
|
||||
if (Array.isArray(emoji.emoticon)) {
|
||||
emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase()))
|
||||
} else {
|
||||
emoticons.push(emoji.emoticon.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
emojiMap.set(emoji.emoji, {
|
||||
keywords: [
|
||||
emoji.label?.toLowerCase(),
|
||||
...(emoji.tags || []).map((tag: string) => tag.toLowerCase()),
|
||||
...emoticons,
|
||||
].filter(Boolean),
|
||||
group: emoji.group,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Enhanced search function using emojibase-data
|
||||
function getEmojiKeywords(emoji: string): string[] {
|
||||
const data = emojiMap.get(emoji)
|
||||
if (data) {
|
||||
return data.keywords
|
||||
}
|
||||
|
||||
// Fallback categories for emojis not in emojibase-data
|
||||
if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) return ['face', 'emotion', 'person', 'expression']
|
||||
if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) return ['animal', 'nature', 'cute', 'pet']
|
||||
if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ['object', 'symbol', 'tool']
|
||||
if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) return ['nature', 'travel', 'activity', 'place']
|
||||
if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) return ['transport', 'travel', 'vehicle']
|
||||
if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ['symbol', 'misc', 'sign']
|
||||
|
||||
return ['misc', 'other']
|
||||
}
|
||||
|
||||
// Color configurations for different accent colors
|
||||
const ACCENT_COLORS = {
|
||||
blue: {
|
||||
gradient: 'linear-gradient(135deg, #74b9ff, #0984e3)',
|
||||
selectedBg: 'blue.100',
|
||||
selectedBorder: 'blue.400',
|
||||
hoverBg: 'blue.200',
|
||||
},
|
||||
pink: {
|
||||
gradient: 'linear-gradient(135deg, #fd79a8, #e84393)',
|
||||
selectedBg: 'pink.100',
|
||||
selectedBorder: 'pink.400',
|
||||
hoverBg: 'pink.200',
|
||||
},
|
||||
purple: {
|
||||
gradient: 'linear-gradient(135deg, #a78bfa, #8b5cf6)',
|
||||
selectedBg: 'purple.100',
|
||||
selectedBorder: 'purple.400',
|
||||
hoverBg: 'purple.200',
|
||||
},
|
||||
yellow: {
|
||||
gradient: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
selectedBg: 'yellow.100',
|
||||
selectedBorder: 'yellow.400',
|
||||
hoverBg: 'yellow.200',
|
||||
},
|
||||
green: {
|
||||
gradient: 'linear-gradient(135deg, #34d399, #10b981)',
|
||||
selectedBg: 'green.100',
|
||||
selectedBorder: 'green.400',
|
||||
hoverBg: 'green.200',
|
||||
},
|
||||
}
|
||||
|
||||
export function EmojiPicker({
|
||||
currentEmoji,
|
||||
onEmojiSelect,
|
||||
onClose,
|
||||
title = 'Choose Avatar',
|
||||
accentColor = 'blue',
|
||||
isDark = false,
|
||||
}: EmojiPickerProps) {
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
|
||||
const [hoveredEmoji, setHoveredEmoji] = useState<string | null>(null)
|
||||
const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
const colors = ACCENT_COLORS[accentColor]
|
||||
|
||||
const isSearching = searchFilter.trim().length > 0
|
||||
const isCategoryFiltered = selectedCategory !== null && !isSearching
|
||||
|
||||
// Calculate which categories have emojis
|
||||
const availableCategories = useMemo(() => {
|
||||
const categoryCounts: Record<number, number> = {}
|
||||
PLAYER_EMOJIS.forEach((emoji) => {
|
||||
const data = emojiMap.get(emoji)
|
||||
if (data && data.group !== undefined) {
|
||||
categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1
|
||||
}
|
||||
})
|
||||
return Object.keys(EMOJI_GROUPS)
|
||||
.map(Number)
|
||||
.filter((groupId) => categoryCounts[groupId] > 0)
|
||||
}, [])
|
||||
|
||||
const displayEmojis = useMemo(() => {
|
||||
let emojis = PLAYER_EMOJIS
|
||||
|
||||
if (isCategoryFiltered) {
|
||||
emojis = emojis.filter((emoji) => {
|
||||
const data = emojiMap.get(emoji)
|
||||
return data && data.group === selectedCategory
|
||||
})
|
||||
}
|
||||
|
||||
if (!isSearching) {
|
||||
return emojis
|
||||
}
|
||||
|
||||
const searchTerm = searchFilter.toLowerCase().trim()
|
||||
|
||||
const results = PLAYER_EMOJIS.filter((emoji) => {
|
||||
const keywords = getEmojiKeywords(emoji)
|
||||
return keywords.some((keyword) => keyword?.includes(searchTerm))
|
||||
})
|
||||
|
||||
// Sort results by relevance
|
||||
const sortedResults = results.sort((a, b) => {
|
||||
const aKeywords = getEmojiKeywords(a)
|
||||
const bKeywords = getEmojiKeywords(b)
|
||||
|
||||
const aExact = aKeywords.some((k) => k === searchTerm)
|
||||
const bExact = bKeywords.some((k) => k === searchTerm)
|
||||
|
||||
if (aExact && !bExact) return -1
|
||||
if (!aExact && bExact) return 1
|
||||
|
||||
const aStartsWithTerm = aKeywords.some((k) => k?.startsWith(searchTerm))
|
||||
const bStartsWithTerm = bKeywords.some((k) => k?.startsWith(searchTerm))
|
||||
|
||||
if (aStartsWithTerm && !bStartsWithTerm) return -1
|
||||
if (!aStartsWithTerm && bStartsWithTerm) return 1
|
||||
|
||||
const aScore = aKeywords.filter((k) => k?.includes(searchTerm)).length
|
||||
const bScore = bKeywords.filter((k) => k?.includes(searchTerm)).length
|
||||
|
||||
return bScore - aScore
|
||||
})
|
||||
|
||||
return sortedResults
|
||||
}, [searchFilter, isSearching, selectedCategory, isCategoryFiltered])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="emoji-picker"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
padding: '20px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '800px',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.100',
|
||||
paddingBottom: '12px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={css({
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
_hover: { color: isDark ? 'gray.200' : 'gray.700' },
|
||||
padding: '4px',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current Selection & Search */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '16px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
style={{ background: colors.gradient }}
|
||||
>
|
||||
<div className={css({ fontSize: '24px' })}>{currentEmoji}</div>
|
||||
<div className={css({ fontSize: '12px', fontWeight: 'bold' })}>Current</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search: face, smart, heart, animal, food..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
bg: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'blue.400',
|
||||
boxShadow: '0 0 0 3px rgba(66, 153, 225, 0.1)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{isSearching && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
flexShrink: 0,
|
||||
padding: '4px 8px',
|
||||
background: displayEmojis.length > 0 ? 'green.100' : 'red.100',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: displayEmojis.length > 0 ? 'green.300' : 'red.300',
|
||||
})}
|
||||
>
|
||||
{displayEmojis.length > 0 ? `✓ ${displayEmojis.length} found` : '✗ No matches'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
{!isSearching && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: isDark ? '#4a5568' : '#cbd5e1',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: selectedCategory === null ? '2px solid #3b82f6' : '2px solid',
|
||||
borderColor:
|
||||
selectedCategory === null ? 'blue.500' : isDark ? 'gray.600' : 'gray.200',
|
||||
background:
|
||||
selectedCategory === null
|
||||
? isDark
|
||||
? 'blue.900'
|
||||
: '#eff6ff'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
color:
|
||||
selectedCategory === null
|
||||
? isDark
|
||||
? 'blue.300'
|
||||
: '#1e40af'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.600',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✨ All
|
||||
</button>
|
||||
{availableCategories.map((groupId) => {
|
||||
const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={groupId}
|
||||
onClick={() => setSelectedCategory(Number(groupId))}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border:
|
||||
selectedCategory === Number(groupId) ? '2px solid #3b82f6' : '2px solid',
|
||||
borderColor:
|
||||
selectedCategory === Number(groupId)
|
||||
? 'blue.500'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
background:
|
||||
selectedCategory === Number(groupId)
|
||||
? isDark
|
||||
? 'blue.900'
|
||||
: '#eff6ff'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
color:
|
||||
selectedCategory === Number(groupId)
|
||||
? isDark
|
||||
? 'blue.300'
|
||||
: '#1e40af'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.600',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{group.icon} {group.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Header */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: isSearching ? 'blue.50' : isDark ? 'gray.700' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: isSearching ? 'blue.200' : isDark ? 'gray.600' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: isSearching ? 'blue.700' : isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
{isSearching
|
||||
? `🔍 Search Results for "${searchFilter}"`
|
||||
: selectedCategory !== null
|
||||
? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}`
|
||||
: '📝 All Available Characters'}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: isSearching ? 'blue.600' : isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{displayEmojis.length} emojis {selectedCategory !== null ? 'in category' : 'available'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji Grid */}
|
||||
{displayEmojis.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: isDark ? '#2d3748' : '#f1f5f9',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: isDark ? '#4a5568' : '#cbd5e1',
|
||||
borderRadius: '5px',
|
||||
'&:hover': {
|
||||
background: isDark ? '#718096' : '#94a3b8',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(16, 1fr)',
|
||||
gap: '4px',
|
||||
padding: '4px',
|
||||
'@media (max-width: 1200px)': {
|
||||
gridTemplateColumns: 'repeat(14, 1fr)',
|
||||
},
|
||||
'@media (max-width: 1000px)': {
|
||||
gridTemplateColumns: 'repeat(12, 1fr)',
|
||||
},
|
||||
'@media (max-width: 800px)': {
|
||||
gridTemplateColumns: 'repeat(10, 1fr)',
|
||||
},
|
||||
'@media (max-width: 600px)': {
|
||||
gridTemplateColumns: 'repeat(8, 1fr)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{displayEmojis.map((emoji) => {
|
||||
const isSelected = emoji === currentEmoji
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={emoji}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
background: isSelected ? colors.selectedBg : 'transparent',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? colors.selectedBorder : 'transparent',
|
||||
borderRadius: '6px',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
background: isSelected ? colors.hoverBg : isDark ? 'gray.600' : 'gray.100',
|
||||
transform: 'scale(1.15)',
|
||||
zIndex: 1,
|
||||
fontSize: '24px',
|
||||
},
|
||||
})}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setHoveredEmoji(emoji)
|
||||
setHoverPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => setHoveredEmoji(null)}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji)
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{isSearching && displayEmojis.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '48px', marginBottom: '16px' })}>🔍</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
No emojis found for "{searchFilter}"
|
||||
</div>
|
||||
<div className={css({ fontSize: '14px', marginBottom: '12px' })}>
|
||||
Try searching for "face", "smart", "heart", "animal", "food", etc.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={css({
|
||||
background: 'blue.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'blue.600' },
|
||||
})}
|
||||
onClick={() => setSearchFilter('')}
|
||||
>
|
||||
Clear search to see all {PLAYER_EMOJIS.length} emojis
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer hint */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '8px',
|
||||
padding: '6px 12px',
|
||||
background: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
💡 Powered by emojibase-data • Try: "face", "smart", "heart", "animal", "food" • Click to
|
||||
select
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magnifying Glass Preview */}
|
||||
{hoveredEmoji && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${hoverPosition.x}px`,
|
||||
top: `${hoverPosition.y - 120}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10001,
|
||||
animation: 'magnifyIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-20px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
|
||||
animation: 'pulseGlow 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
boxShadow:
|
||||
'0 20px 60px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(59, 130, 246, 0.6), inset 0 2px 4px rgba(255,255,255,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '120px',
|
||||
lineHeight: 1,
|
||||
minWidth: '160px',
|
||||
minHeight: '160px',
|
||||
position: 'relative',
|
||||
animation: 'emojiFloat 3s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
fontSize: '20px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '15px',
|
||||
left: '15px',
|
||||
fontSize: '16px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0.5s',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
{hoveredEmoji}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '14px solid transparent',
|
||||
borderRight: '14px solid transparent',
|
||||
borderTop: '14px solid white',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes magnifyIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes emojiFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,12 @@ interface StudentFilterBarProps {
|
||||
onEditModeChange: (editing: boolean) => void
|
||||
/** Number of archived students (for badge) */
|
||||
archivedCount: number
|
||||
/** Callback when add student button is clicked */
|
||||
onAddStudent?: () => void
|
||||
/** Number of selected students in edit mode */
|
||||
selectedCount?: number
|
||||
/** Callback when bulk archive is clicked */
|
||||
onBulkArchive?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +58,9 @@ export function StudentFilterBar({
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
archivedCount,
|
||||
onAddStudent,
|
||||
selectedCount = 0,
|
||||
onBulkArchive,
|
||||
}: StudentFilterBarProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
@@ -125,6 +134,10 @@ export function StudentFilterBar({
|
||||
<div
|
||||
data-component="student-filter-bar"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '80px', // Below nav
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
@@ -132,9 +145,10 @@ export function StudentFilterBar({
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
zIndex: Z_INDEX.FILTER_BAR,
|
||||
})}
|
||||
>
|
||||
{/* Top row: Search input and buttons */}
|
||||
{/* Top row: Search/bulk actions and buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -143,220 +157,274 @@ export function StudentFilterBar({
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{/* Search input with dropdown */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
flex: '1',
|
||||
minWidth: '200px',
|
||||
})}
|
||||
>
|
||||
{editMode ? (
|
||||
/* Edit mode: Show bulk actions */
|
||||
<div
|
||||
data-element="bulk-actions"
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '200px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
bg: isDark ? 'gray.700' : 'gray.50',
|
||||
bg: isDark ? 'amber.900/50' : 'amber.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderColor: isDark ? 'amber.700' : 'amber.200',
|
||||
borderRadius: '8px',
|
||||
_focusWithin: {
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search students or skills..."
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (skillResults.length > 0) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}}
|
||||
data-element="search-input"
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
fontSize: '14px',
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
},
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'amber.200' : 'amber.700',
|
||||
})}
|
||||
/>
|
||||
{localQuery && (
|
||||
>
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
{selectedCount > 0 && onBulkArchive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocalQuery('')}
|
||||
data-action="clear-search"
|
||||
onClick={onBulkArchive}
|
||||
data-action="bulk-archive"
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
padding: '6px 12px',
|
||||
bg: isDark ? 'red.700' : 'red.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
_hover: { color: isDark ? 'gray.300' : 'gray.700' },
|
||||
_hover: {
|
||||
bg: isDark ? 'red.600' : 'red.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✕
|
||||
Archive Selected
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skill autocomplete dropdown */}
|
||||
{showDropdown && (
|
||||
) : (
|
||||
/* Normal mode: Show search and archive toggle */
|
||||
<>
|
||||
{/* Search input with dropdown */}
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
data-element="skill-dropdown"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'lg',
|
||||
zIndex: Z_INDEX.DROPDOWN,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
position: 'relative',
|
||||
flex: '1',
|
||||
minWidth: '200px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.100',
|
||||
bg: isDark ? 'gray.700' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: '8px',
|
||||
_focusWithin: {
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Add skill filter (AND logic)
|
||||
</div>
|
||||
{skillResults.slice(0, 10).map((skill) => (
|
||||
<button
|
||||
key={skill.skillId}
|
||||
type="button"
|
||||
onClick={() => handleAddSkillFilter(skill.skillId)}
|
||||
data-action="add-skill-filter"
|
||||
data-skill-id={skill.skillId}
|
||||
<span className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search students or skills..."
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (skillResults.length > 0) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}}
|
||||
data-element="search-input"
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '2px',
|
||||
padding: '10px 12px',
|
||||
flex: 1,
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
_hover: { bg: isDark ? 'gray.700' : 'gray.50' },
|
||||
outline: 'none',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
fontSize: '14px',
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{localQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocalQuery('')}
|
||||
data-action="clear-search"
|
||||
className={css({
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
_hover: { color: isDark ? 'gray.300' : 'gray.700' },
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skill autocomplete dropdown */}
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
data-element="skill-dropdown"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'lg',
|
||||
zIndex: Z_INDEX.DROPDOWN,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.100',
|
||||
})}
|
||||
>
|
||||
{skill.displayName}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
{skill.categoryName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
Add skill filter (AND logic)
|
||||
</div>
|
||||
{skillResults.slice(0, 10).map((skill) => (
|
||||
<button
|
||||
key={skill.skillId}
|
||||
type="button"
|
||||
onClick={() => handleAddSkillFilter(skill.skillId)}
|
||||
data-action="add-skill-filter"
|
||||
data-skill-id={skill.skillId}
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '2px',
|
||||
padding: '10px 12px',
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
_hover: { bg: isDark ? 'gray.700' : 'gray.50' },
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
{skill.displayName}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
{skill.categoryName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Archive toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowArchivedChange(!showArchived)}
|
||||
data-action="toggle-archived"
|
||||
data-status={showArchived ? 'showing' : 'hiding'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
bg: showArchived
|
||||
? isDark
|
||||
? 'blue.900'
|
||||
: 'blue.100'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor: showArchived
|
||||
? isDark
|
||||
? 'blue.700'
|
||||
: 'blue.300'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: showArchived
|
||||
? isDark
|
||||
? 'blue.300'
|
||||
: 'blue.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.700',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
borderColor: showArchived
|
||||
? isDark
|
||||
? 'blue.600'
|
||||
: 'blue.400'
|
||||
: isDark
|
||||
? 'gray.500'
|
||||
: 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{showArchived ? '👁' : '👁🗨'}</span>
|
||||
<span>Archived</span>
|
||||
{archivedCount > 0 && (
|
||||
<span
|
||||
{/* Archive toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowArchivedChange(!showArchived)}
|
||||
data-action="toggle-archived"
|
||||
data-status={showArchived ? 'showing' : 'hiding'}
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 'medium',
|
||||
padding: '2px 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
bg: showArchived
|
||||
? isDark
|
||||
? 'blue.800'
|
||||
: 'blue.200'
|
||||
? 'blue.900'
|
||||
: 'blue.100'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor: showArchived
|
||||
? isDark
|
||||
? 'blue.700'
|
||||
: 'blue.300'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
borderRadius: '10px',
|
||||
: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: showArchived
|
||||
? isDark
|
||||
? 'blue.300'
|
||||
: 'blue.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.700',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
borderColor: showArchived
|
||||
? isDark
|
||||
? 'blue.600'
|
||||
: 'blue.400'
|
||||
: isDark
|
||||
? 'gray.500'
|
||||
: 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{archivedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span>{showArchived ? '👁' : '👁🗨'}</span>
|
||||
<span>Archived</span>
|
||||
{archivedCount > 0 && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontWeight: 'medium',
|
||||
padding: '2px 6px',
|
||||
bg: showArchived
|
||||
? isDark
|
||||
? 'blue.800'
|
||||
: 'blue.200'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
borderRadius: '10px',
|
||||
})}
|
||||
>
|
||||
{archivedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit mode toggle button */}
|
||||
{/* Edit mode toggle button - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditModeChange(!editMode)}
|
||||
@@ -401,6 +469,44 @@ export function StudentFilterBar({
|
||||
<span>{editMode ? '✓' : '✏️'}</span>
|
||||
<span>{editMode ? 'Done' : 'Edit'}</span>
|
||||
</button>
|
||||
|
||||
{/* Add Student FAB - only in normal mode */}
|
||||
{!editMode && onAddStudent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddStudent}
|
||||
data-action="add-student-fab"
|
||||
title="Add Student"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
bg: isDark ? 'green.600' : 'green.500',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
color: 'white',
|
||||
boxShadow:
|
||||
'0 3px 5px -1px rgba(0,0,0,0.2), 0 6px 10px 0 rgba(0,0,0,0.14), 0 1px 18px 0 rgba(0,0,0,0.12)',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
flexShrink: 0,
|
||||
_hover: {
|
||||
bg: isDark ? 'green.500' : 'green.600',
|
||||
boxShadow:
|
||||
'0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skill filter pills */}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useUpdatePlayer } from '@/hooks/useUserPlayers'
|
||||
import type { Player } from '@/types/player'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { NotesModal } from './NotesModal'
|
||||
@@ -100,8 +102,10 @@ function StudentCard({ student, onSelect, onOpenNotes, editMode, isSelected }: S
|
||||
>
|
||||
{/* Edit mode checkbox */}
|
||||
{editMode && (
|
||||
<div
|
||||
<Checkbox.Root
|
||||
data-element="checkbox"
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onSelect(student)}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
@@ -115,13 +119,24 @@ function StudentCard({ student, onSelect, onOpenNotes, editMode, isSelected }: S
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
zIndex: 1,
|
||||
_hover: {
|
||||
borderColor: 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isSelected && '✓'}
|
||||
</div>
|
||||
<Checkbox.Indicator
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
)}
|
||||
|
||||
{/* Archived badge */}
|
||||
@@ -261,17 +276,22 @@ function StudentCard({ student, onSelect, onOpenNotes, editMode, isSelected }: S
|
||||
)
|
||||
}
|
||||
|
||||
interface AddStudentButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to manage students page
|
||||
* Button to add a new student
|
||||
*/
|
||||
function ManageStudentsLink() {
|
||||
function AddStudentButton({ onClick }: AddStudentButtonProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
return (
|
||||
<a
|
||||
href="/students"
|
||||
data-action="manage-students"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-action="add-student"
|
||||
className={css({
|
||||
...centerStack,
|
||||
justifyContent: 'center',
|
||||
@@ -285,17 +305,16 @@ function ManageStudentsLink() {
|
||||
cursor: 'pointer',
|
||||
minWidth: '100px',
|
||||
minHeight: '140px',
|
||||
textDecoration: 'none',
|
||||
_hover: {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: themed('info', isDark),
|
||||
borderColor: 'green.400',
|
||||
backgroundColor: isDark ? 'green.900/30' : 'green.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
color: themed('textSubtle', isDark),
|
||||
fontSize: '2rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
>
|
||||
+
|
||||
@@ -307,18 +326,21 @@ function ManageStudentsLink() {
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Manage Students
|
||||
Add Student
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface StudentSelectorProps {
|
||||
students: StudentWithProgress[]
|
||||
onSelectStudent: (student: StudentWithProgress) => void
|
||||
onAddStudent?: () => void
|
||||
title?: string
|
||||
editMode?: boolean
|
||||
selectedIds?: Set<string>
|
||||
/** Hide the add student button (e.g., when showing filtered subsets) */
|
||||
hideAddButton?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,9 +355,11 @@ interface StudentSelectorProps {
|
||||
export function StudentSelector({
|
||||
students,
|
||||
onSelectStudent,
|
||||
onAddStudent,
|
||||
title = 'Who is practicing today?',
|
||||
editMode = false,
|
||||
selectedIds = new Set(),
|
||||
hideAddButton = false,
|
||||
}: StudentSelectorProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
@@ -346,13 +370,8 @@ export function StudentSelector({
|
||||
useState<StudentWithProgress | null>(null)
|
||||
const [sourceBounds, setSourceBounds] = useState<DOMRect | null>(null)
|
||||
|
||||
// Track students with local state for optimistic updates
|
||||
const [localStudents, setLocalStudents] = useState(students)
|
||||
|
||||
// Update local students when props change
|
||||
if (students !== localStudents && !notesModalOpen) {
|
||||
setLocalStudents(students)
|
||||
}
|
||||
// Use React Query mutation for updates
|
||||
const updatePlayer = useUpdatePlayer()
|
||||
|
||||
const handleOpenNotes = useCallback((student: StudentWithProgress, bounds: DOMRect) => {
|
||||
setSelectedStudentForNotes(student)
|
||||
@@ -368,25 +387,15 @@ export function StudentSelector({
|
||||
async (notes: string) => {
|
||||
if (!selectedStudentForNotes) return
|
||||
|
||||
const response = await fetch(`/api/players/${selectedStudentForNotes.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes: notes || null }),
|
||||
await updatePlayer.mutateAsync({
|
||||
id: selectedStudentForNotes.id,
|
||||
updates: { notes: notes || null },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save notes')
|
||||
}
|
||||
|
||||
// Optimistically update local state
|
||||
setLocalStudents((prev) =>
|
||||
prev.map((s) => (s.id === selectedStudentForNotes.id ? { ...s, notes: notes || null } : s))
|
||||
)
|
||||
|
||||
// Update the selected student for modal
|
||||
// Update the selected student for modal display
|
||||
setSelectedStudentForNotes((prev) => (prev ? { ...prev, notes: notes || null } : null))
|
||||
},
|
||||
[selectedStudentForNotes]
|
||||
[selectedStudentForNotes, updatePlayer]
|
||||
)
|
||||
|
||||
const handleToggleArchive = useCallback(async () => {
|
||||
@@ -394,26 +403,14 @@ export function StudentSelector({
|
||||
|
||||
const newArchivedState = !selectedStudentForNotes.isArchived
|
||||
|
||||
const response = await fetch(`/api/players/${selectedStudentForNotes.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isArchived: newArchivedState }),
|
||||
await updatePlayer.mutateAsync({
|
||||
id: selectedStudentForNotes.id,
|
||||
updates: { isArchived: newArchivedState },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to toggle archive status')
|
||||
}
|
||||
|
||||
// Optimistically update local state
|
||||
setLocalStudents((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === selectedStudentForNotes.id ? { ...s, isArchived: newArchivedState } : s
|
||||
)
|
||||
)
|
||||
|
||||
// Update the selected student for modal
|
||||
// Update the selected student for modal display
|
||||
setSelectedStudentForNotes((prev) => (prev ? { ...prev, isArchived: newArchivedState } : null))
|
||||
}, [selectedStudentForNotes])
|
||||
}, [selectedStudentForNotes, updatePlayer])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -444,7 +441,7 @@ export function StudentSelector({
|
||||
...gapMd,
|
||||
})}
|
||||
>
|
||||
{localStudents.map((student) => (
|
||||
{students.map((student) => (
|
||||
<StudentCard
|
||||
key={student.id}
|
||||
student={student}
|
||||
@@ -455,7 +452,7 @@ export function StudentSelector({
|
||||
/>
|
||||
))}
|
||||
|
||||
<ManageStudentsLink />
|
||||
{!hideAddButton && onAddStudent && <AddStudentButton onClick={onAddStudent} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ export const Z_INDEX = {
|
||||
// Navigation and UI chrome (100-999)
|
||||
NAV_BAR: 100,
|
||||
STICKY_HEADER: 100,
|
||||
FILTER_BAR: 99, // Fixed filter bar, just below nav
|
||||
STICKY_BUCKET_HEADER: 98, // Recency bucket headers (Today, This Week, etc.)
|
||||
STICKY_CATEGORY_HEADER: 97, // Skill category headers within buckets
|
||||
SUB_NAV: 90,
|
||||
SESSION_MODE_BANNER: 85, // Below sub-nav, but above content
|
||||
SESSION_MODE_BANNER_ANIMATING: 150, // Above all nav during FLIP animation
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tansta
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { playerKeys } from '@/lib/queryKeys'
|
||||
import type { StudentWithSkillData } from '@/utils/studentGrouping'
|
||||
|
||||
// Re-export query keys for consumers
|
||||
export { playerKeys } from '@/lib/queryKeys'
|
||||
@@ -18,6 +19,16 @@ async function fetchPlayers(): Promise<Player[]> {
|
||||
return data.players
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all players with skill data for the current user
|
||||
*/
|
||||
async function fetchPlayersWithSkillData(): Promise<StudentWithSkillData[]> {
|
||||
const res = await api('players/with-skill-data')
|
||||
if (!res.ok) throw new Error('Failed to fetch players with skill data')
|
||||
const data = await res.json()
|
||||
return data.players
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new player
|
||||
*/
|
||||
@@ -42,7 +53,7 @@ async function updatePlayer({
|
||||
updates,
|
||||
}: {
|
||||
id: string
|
||||
updates: Partial<Pick<Player, 'name' | 'emoji' | 'color' | 'isActive'>>
|
||||
updates: Partial<Pick<Player, 'name' | 'emoji' | 'color' | 'isActive' | 'isArchived' | 'notes'>>
|
||||
}): Promise<Player> {
|
||||
const res = await api(`players/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -92,6 +103,20 @@ export function useUserPlayersSuspense() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Fetch all players with skill data
|
||||
* Used by the practice page for grouping/filtering
|
||||
*/
|
||||
export function usePlayersWithSkillData(options?: { initialData?: StudentWithSkillData[] }) {
|
||||
return useQuery({
|
||||
queryKey: playerKeys.listWithSkillData(),
|
||||
queryFn: fetchPlayersWithSkillData,
|
||||
initialData: options?.initialData,
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 30_000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Fetch a single player with Suspense (for SSR contexts)
|
||||
*/
|
||||
@@ -129,6 +154,7 @@ export function useCreatePlayer() {
|
||||
...newPlayer,
|
||||
createdAt: new Date(),
|
||||
isActive: newPlayer.isActive ?? false,
|
||||
isArchived: false,
|
||||
userId: 'temp-user', // Temporary userId, will be replaced by server response
|
||||
helpSettings: null, // Will be set by server with default values
|
||||
notes: null,
|
||||
@@ -163,13 +189,16 @@ export function useUpdatePlayer() {
|
||||
return useMutation({
|
||||
mutationFn: updatePlayer,
|
||||
onMutate: async ({ id, updates }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: playerKeys.lists() })
|
||||
// Cancel outgoing refetches for all player lists
|
||||
await queryClient.cancelQueries({ queryKey: playerKeys.all })
|
||||
|
||||
// Snapshot previous value
|
||||
// Snapshot previous values
|
||||
const previousPlayers = queryClient.getQueryData<Player[]>(playerKeys.list())
|
||||
const previousPlayersWithSkillData = queryClient.getQueryData<StudentWithSkillData[]>(
|
||||
playerKeys.listWithSkillData()
|
||||
)
|
||||
|
||||
// Optimistically update
|
||||
// Optimistically update player list
|
||||
if (previousPlayers) {
|
||||
const optimisticPlayers = previousPlayers.map((player) =>
|
||||
player.id === id ? { ...player, ...updates } : player
|
||||
@@ -177,7 +206,18 @@ export function useUpdatePlayer() {
|
||||
queryClient.setQueryData<Player[]>(playerKeys.list(), optimisticPlayers)
|
||||
}
|
||||
|
||||
return { previousPlayers }
|
||||
// Optimistically update players with skill data
|
||||
if (previousPlayersWithSkillData) {
|
||||
const optimisticPlayers = previousPlayersWithSkillData.map((player) =>
|
||||
player.id === id ? { ...player, ...updates } : player
|
||||
)
|
||||
queryClient.setQueryData<StudentWithSkillData[]>(
|
||||
playerKeys.listWithSkillData(),
|
||||
optimisticPlayers
|
||||
)
|
||||
}
|
||||
|
||||
return { previousPlayers, previousPlayersWithSkillData }
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
// Log error for debugging
|
||||
@@ -187,10 +227,16 @@ export function useUpdatePlayer() {
|
||||
if (context?.previousPlayers) {
|
||||
queryClient.setQueryData(playerKeys.list(), context.previousPlayers)
|
||||
}
|
||||
if (context?.previousPlayersWithSkillData) {
|
||||
queryClient.setQueryData(
|
||||
playerKeys.listWithSkillData(),
|
||||
context.previousPlayersWithSkillData
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, { id }) => {
|
||||
// Refetch after error or success
|
||||
queryClient.invalidateQueries({ queryKey: playerKeys.lists() })
|
||||
// Refetch after error or success - invalidate all player queries
|
||||
queryClient.invalidateQueries({ queryKey: playerKeys.all })
|
||||
if (_data) {
|
||||
queryClient.setQueryData(playerKeys.detail(id), _data)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const playerKeys = {
|
||||
all: ['players'] as const,
|
||||
lists: () => [...playerKeys.all, 'list'] as const,
|
||||
list: () => [...playerKeys.lists()] as const,
|
||||
listWithSkillData: () => [...playerKeys.all, 'listWithSkillData'] as const,
|
||||
detail: (id: string) => [...playerKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user