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:
Thomas Hallock
2025-12-20 12:17:12 -06:00
parent 538718a814
commit 0e0356113d
9 changed files with 1711 additions and 320 deletions

View 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 })
}
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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,
}