15 changed files with 1866 additions and 16 deletions
@ -0,0 +1,266 @@
@@ -0,0 +1,266 @@
|
||||
import { Response } from 'express'; |
||||
import { AuthRequest } from '../types/index.js'; |
||||
import { db } from '../config/database.js'; |
||||
|
||||
export async function getAllWordGroups(req: AuthRequest, res: Response) { |
||||
try { |
||||
const groups = await db.execute(` |
||||
SELECT wg.*, COUNT(w.id) as word_count |
||||
FROM word_groups wg |
||||
LEFT JOIN words w ON wg.id = w.word_group_id |
||||
GROUP BY wg.id |
||||
ORDER BY wg.created_at DESC |
||||
`);
|
||||
|
||||
const groupsWithWords = await Promise.all( |
||||
groups.rows.map(async (group) => { |
||||
const words = await db.execute({ |
||||
sql: 'SELECT id, word FROM words WHERE word_group_id = ? ORDER BY word', |
||||
args: [group.id] |
||||
}); |
||||
|
||||
return { |
||||
id: group.id, |
||||
name: group.name, |
||||
wordCount: group.word_count, |
||||
words: words.rows.map(w => ({ |
||||
id: w.id, |
||||
word: w.word |
||||
})), |
||||
createdAt: group.created_at, |
||||
updatedAt: group.updated_at |
||||
}; |
||||
}) |
||||
); |
||||
|
||||
res.json({ |
||||
success: true, |
||||
data: { |
||||
groups: groupsWithWords |
||||
}, |
||||
meta: { |
||||
total: groupsWithWords.length |
||||
} |
||||
}); |
||||
} catch (error: any) { |
||||
console.error('Get word groups error:', error); |
||||
res.status(500).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'GET_WORD_GROUPS_ERROR', |
||||
message: 'Error fetching word groups' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export async function createWordGroup(req: AuthRequest, res: Response) { |
||||
try { |
||||
const { name } = req.body; |
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) { |
||||
return res.status(400).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'INVALID_NAME', |
||||
message: 'Group name is required' |
||||
} |
||||
}); |
||||
} |
||||
|
||||
const result = await db.execute({ |
||||
sql: `INSERT INTO word_groups (name, updated_at)
|
||||
VALUES (?, ?)`,
|
||||
args: [name.trim(), new Date().toISOString()] |
||||
}); |
||||
|
||||
const groupId = Number(result.lastInsertRowid); |
||||
|
||||
res.status(201).json({ |
||||
success: true, |
||||
data: { |
||||
group: { |
||||
id: groupId, |
||||
name: name.trim(), |
||||
wordCount: 0, |
||||
words: [], |
||||
createdAt: new Date().toISOString(), |
||||
updatedAt: new Date().toISOString() |
||||
} |
||||
} |
||||
}); |
||||
} catch (error: any) { |
||||
console.error('Create word group error:', error); |
||||
res.status(500).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'CREATE_WORD_GROUP_ERROR', |
||||
message: 'Error creating word group' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export async function updateWordGroup(req: AuthRequest, res: Response) { |
||||
try { |
||||
const { id } = req.params; |
||||
const { name } = req.body; |
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) { |
||||
return res.status(400).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'INVALID_NAME', |
||||
message: 'Group name is required' |
||||
} |
||||
}); |
||||
} |
||||
|
||||
await db.execute({ |
||||
sql: `UPDATE word_groups
|
||||
SET name = ?, updated_at = ? |
||||
WHERE id = ?`,
|
||||
args: [name.trim(), new Date().toISOString(), id] |
||||
}); |
||||
|
||||
res.json({ |
||||
success: true, |
||||
data: { |
||||
message: 'Word group updated successfully' |
||||
} |
||||
}); |
||||
} catch (error: any) { |
||||
console.error('Update word group error:', error); |
||||
res.status(500).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'UPDATE_WORD_GROUP_ERROR', |
||||
message: 'Error updating word group' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export async function deleteWordGroup(req: AuthRequest, res: Response) { |
||||
try { |
||||
const { id } = req.params; |
||||
|
||||
await db.execute({ |
||||
sql: 'DELETE FROM word_groups WHERE id = ?', |
||||
args: [id] |
||||
}); |
||||
|
||||
res.json({ |
||||
success: true, |
||||
data: { |
||||
message: 'Word group deleted successfully' |
||||
} |
||||
}); |
||||
} catch (error: any) { |
||||
console.error('Delete word group error:', error); |
||||
res.status(500).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'DELETE_WORD_GROUP_ERROR', |
||||
message: 'Error deleting word group' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export async function addWord(req: AuthRequest, res: Response) { |
||||
try { |
||||
// Try both possible parameter names (groupId from route, id if wrong route matched)
|
||||
const groupId = req.params.groupId || req.params.id; |
||||
const { word } = req.body; |
||||
|
||||
if (!groupId) { |
||||
console.error('addWord - Missing groupId. req.params:', req.params, 'req.url:', req.url); |
||||
return res.status(400).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'MISSING_GROUP_ID', |
||||
message: 'Group ID is required' |
||||
} |
||||
}); |
||||
} |
||||
|
||||
if (!word || typeof word !== 'string' || word.trim().length === 0) { |
||||
return res.status(400).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'INVALID_WORD', |
||||
message: 'Word is required' |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// Check if group exists
|
||||
const groupCheck = await db.execute({ |
||||
sql: 'SELECT id FROM word_groups WHERE id = ?', |
||||
args: [parseInt(groupId, 10)] |
||||
}); |
||||
|
||||
if (groupCheck.rows.length === 0) { |
||||
return res.status(404).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'GROUP_NOT_FOUND', |
||||
message: 'Word group not found' |
||||
} |
||||
}); |
||||
} |
||||
|
||||
const result = await db.execute({ |
||||
sql: 'INSERT INTO words (word_group_id, word) VALUES (?, ?)', |
||||
args: [parseInt(groupId, 10), word.trim()] |
||||
}); |
||||
|
||||
res.status(201).json({ |
||||
success: true, |
||||
data: { |
||||
word: { |
||||
id: Number(result.lastInsertRowid), |
||||
word: word.trim(), |
||||
wordGroupId: parseInt(groupId, 10) |
||||
} |
||||
} |
||||
}); |
||||
} catch (error: any) { |
||||
console.error('Add word error:', error); |
||||
res.status(500).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'ADD_WORD_ERROR', |
||||
message: 'Error adding word' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
export async function deleteWord(req: AuthRequest, res: Response) { |
||||
try { |
||||
const id = req.params.wordId || req.params.id; |
||||
|
||||
await db.execute({ |
||||
sql: 'DELETE FROM words WHERE id = ?', |
||||
args: [id] |
||||
}); |
||||
|
||||
res.json({ |
||||
success: true, |
||||
data: { |
||||
message: 'Word deleted successfully' |
||||
} |
||||
}); |
||||
} catch (error: any) { |
||||
console.error('Delete word error:', error); |
||||
res.status(500).json({ |
||||
success: false, |
||||
error: { |
||||
code: 'DELETE_WORD_ERROR', |
||||
message: 'Error deleting word' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express'; |
||||
import { |
||||
getAllWordGroups, |
||||
createWordGroup, |
||||
updateWordGroup, |
||||
deleteWordGroup, |
||||
addWord, |
||||
deleteWord |
||||
} from '../controllers/wordGroups.controller.js'; |
||||
import { authMiddleware } from '../middleware/auth.js'; |
||||
|
||||
const router = Router(); |
||||
|
||||
// All routes require authentication
|
||||
router.use(authMiddleware); |
||||
|
||||
// Word group routes (base routes first)
|
||||
router.get('/', getAllWordGroups); |
||||
router.post('/', createWordGroup); |
||||
|
||||
// Word routes - must come before generic :id routes
|
||||
// More specific routes first
|
||||
router.post('/:groupId/words', addWord); |
||||
router.delete('/words/:wordId', deleteWord); |
||||
|
||||
// Word group routes with IDs (generic routes last)
|
||||
router.put('/:id', updateWordGroup); |
||||
router.delete('/:id', deleteWordGroup); |
||||
|
||||
export default router; |
||||
@ -0,0 +1,370 @@
@@ -0,0 +1,370 @@
|
||||
.word-group-manager { |
||||
width: 100%; |
||||
padding: 24px; |
||||
background: var(--color-surface); |
||||
border-radius: 12px; |
||||
border: 1px solid rgba(212, 222, 239, 0.8); |
||||
} |
||||
|
||||
.word-group-header { |
||||
margin-bottom: 24px; |
||||
} |
||||
|
||||
.word-group-header h2 { |
||||
margin: 0 0 8px 0; |
||||
font-size: 20px; |
||||
font-weight: 600; |
||||
color: var(--color-text); |
||||
} |
||||
|
||||
.word-group-header p { |
||||
margin: 0; |
||||
font-size: 14px; |
||||
color: var(--color-muted); |
||||
} |
||||
|
||||
.create-group-form { |
||||
display: flex; |
||||
gap: 12px; |
||||
margin-bottom: 24px; |
||||
} |
||||
|
||||
.group-name-input { |
||||
flex: 1; |
||||
padding: 12px 16px; |
||||
border: 1px solid rgba(212, 222, 239, 0.8); |
||||
border-radius: 6px; |
||||
font-size: 14px; |
||||
background: var(--color-surface-muted); |
||||
color: var(--color-text); |
||||
transition: border-color 0.2s; |
||||
} |
||||
|
||||
.group-name-input:focus { |
||||
outline: none; |
||||
border-color: var(--color-primary); |
||||
} |
||||
|
||||
.create-group-btn { |
||||
padding: 12px 24px; |
||||
background: linear-gradient( |
||||
135deg, |
||||
var(--color-primary), |
||||
var(--color-secondary) |
||||
); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 6px; |
||||
font-size: 14px; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: |
||||
transform 0.2s, |
||||
box-shadow 0.2s; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.create-group-btn:hover:not(:disabled) { |
||||
transform: translateY(-2px); |
||||
box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3); |
||||
} |
||||
|
||||
.create-group-btn:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.empty-state { |
||||
text-align: center; |
||||
padding: 48px 24px; |
||||
color: var(--color-muted); |
||||
} |
||||
|
||||
.empty-state p { |
||||
margin: 0; |
||||
font-size: 14px; |
||||
} |
||||
|
||||
.groups-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 16px; |
||||
} |
||||
|
||||
.word-group-card { |
||||
background: var(--color-surface-muted); |
||||
border: 1px solid rgba(212, 222, 239, 0.5); |
||||
border-radius: 8px; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.group-header { |
||||
padding: 16px; |
||||
background: var(--color-surface); |
||||
border-bottom: 1px solid rgba(212, 222, 239, 0.5); |
||||
} |
||||
|
||||
.group-info { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
.group-name { |
||||
margin: 0; |
||||
font-size: 18px; |
||||
font-weight: 600; |
||||
color: var(--color-text); |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
transition: color 0.2s; |
||||
} |
||||
|
||||
.group-name:hover { |
||||
color: var(--color-primary); |
||||
} |
||||
|
||||
.word-count { |
||||
font-size: 14px; |
||||
font-weight: 400; |
||||
color: var(--color-muted); |
||||
} |
||||
|
||||
.group-actions { |
||||
display: flex; |
||||
gap: 8px; |
||||
} |
||||
|
||||
.edit-btn, |
||||
.delete-btn { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 18px; |
||||
cursor: pointer; |
||||
padding: 4px 8px; |
||||
border-radius: 4px; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.edit-btn:hover, |
||||
.delete-btn:hover { |
||||
background: rgba(212, 222, 239, 0.3); |
||||
} |
||||
|
||||
.edit-group-form { |
||||
display: flex; |
||||
gap: 8px; |
||||
align-items: center; |
||||
} |
||||
|
||||
.edit-group-input { |
||||
flex: 1; |
||||
padding: 8px 12px; |
||||
border: 1px solid var(--color-primary); |
||||
border-radius: 4px; |
||||
font-size: 16px; |
||||
font-weight: 600; |
||||
background: var(--color-surface-muted); |
||||
color: var(--color-text); |
||||
} |
||||
|
||||
.edit-group-input:focus { |
||||
outline: none; |
||||
border-color: var(--color-primary); |
||||
} |
||||
|
||||
.save-btn, |
||||
.cancel-btn { |
||||
padding: 8px 16px; |
||||
border: none; |
||||
border-radius: 4px; |
||||
font-size: 14px; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.save-btn { |
||||
background: var(--color-primary); |
||||
color: white; |
||||
} |
||||
|
||||
.save-btn:hover { |
||||
background: var(--color-secondary); |
||||
} |
||||
|
||||
.cancel-btn { |
||||
background: transparent; |
||||
color: var(--color-text); |
||||
border: 1px solid rgba(212, 222, 239, 0.8); |
||||
} |
||||
|
||||
.cancel-btn:hover { |
||||
background: var(--color-surface-muted); |
||||
} |
||||
|
||||
.group-content { |
||||
padding: 16px; |
||||
} |
||||
|
||||
.words-list { |
||||
margin-bottom: 16px; |
||||
} |
||||
|
||||
.no-words { |
||||
text-align: center; |
||||
color: var(--color-muted); |
||||
font-size: 14px; |
||||
margin: 16px 0; |
||||
} |
||||
|
||||
.words-grid { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 8px; |
||||
margin-bottom: 16px; |
||||
} |
||||
|
||||
.word-item { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
padding: 8px 16px; |
||||
background: var(--color-surface); |
||||
border: 1px solid rgba(212, 222, 239, 0.5); |
||||
border-radius: 999px; |
||||
transition: |
||||
border-color 0.2s, |
||||
background-color 0.2s; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.word-item:hover { |
||||
border-color: var(--color-primary); |
||||
background: var(--color-surface-muted); |
||||
} |
||||
|
||||
.practice-area { |
||||
.word-text { |
||||
font-size: 48px; |
||||
} |
||||
} |
||||
.word-text { |
||||
font-size: 14px; |
||||
font-weight: 500; |
||||
color: var(--color-text); |
||||
margin-right: 8px; |
||||
line-height: 1.2; |
||||
display: inline-block; |
||||
vertical-align: middle; |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.delete-word-btn { |
||||
background: transparent; |
||||
border: none; |
||||
color: var(--color-muted); |
||||
font-size: 20px; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
padding: 0; |
||||
width: 20px; |
||||
height: 20px; |
||||
display: inline-flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
border-radius: 50%; |
||||
flex-shrink: 0; |
||||
margin-left: 4px; |
||||
vertical-align: middle; |
||||
transition: |
||||
background-color 0.2s, |
||||
color 0.2s; |
||||
} |
||||
|
||||
.delete-word-btn:hover { |
||||
background: rgba(220, 53, 69, 0.1); |
||||
color: #dc3545; |
||||
} |
||||
|
||||
.add-word-form { |
||||
display: flex; |
||||
gap: 8px; |
||||
} |
||||
|
||||
.word-input { |
||||
flex: 1; |
||||
padding: 10px 12px; |
||||
border: 1px solid rgba(212, 222, 239, 0.8); |
||||
border-radius: 6px; |
||||
font-size: 14px; |
||||
background: var(--color-surface); |
||||
color: var(--color-text); |
||||
transition: border-color 0.2s; |
||||
} |
||||
|
||||
.word-input:focus { |
||||
outline: none; |
||||
border-color: var(--color-primary); |
||||
} |
||||
|
||||
.add-word-btn { |
||||
padding: 10px 20px; |
||||
background: var(--color-primary); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 6px; |
||||
font-size: 14px; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: background-color 0.2s; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.add-word-btn:hover:not(:disabled) { |
||||
background: var(--color-secondary); |
||||
} |
||||
|
||||
.add-word-btn:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.alert { |
||||
padding: 12px 16px; |
||||
border-radius: 6px; |
||||
margin-bottom: 16px; |
||||
font-size: 14px; |
||||
} |
||||
|
||||
.alert-error { |
||||
background-color: #fef2f2; |
||||
color: #991b1b; |
||||
border: 1px solid #fecaca; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.word-group-manager { |
||||
padding: 16px; |
||||
} |
||||
|
||||
.create-group-form { |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.group-info { |
||||
flex-direction: column; |
||||
align-items: flex-start; |
||||
gap: 12px; |
||||
} |
||||
|
||||
.group-actions { |
||||
align-self: flex-end; |
||||
} |
||||
|
||||
.add-word-form { |
||||
flex-direction: column; |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
import { WordGroupManager } from '../components/WordGroupManager/WordGroupManager'; |
||||
import { Link } from 'react-router-dom'; |
||||
import './AdminPage.css'; |
||||
|
||||
export function SpeechSoundsAdminPage() { |
||||
return ( |
||||
<div className="admin-page"> |
||||
<div className="admin-header"> |
||||
<Link to="/admin" className="back-button"> |
||||
← Back to Admin |
||||
</Link> |
||||
<h1>Speech Sounds - Word Groups</h1> |
||||
<p>Manage word groups for speech sound practice</p> |
||||
</div> |
||||
<WordGroupManager /> |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,341 @@
@@ -0,0 +1,341 @@
|
||||
.speech-sounds-app { |
||||
min-height: calc(100vh - 60px); |
||||
background-color: #f9f9f9; |
||||
padding: 24px; |
||||
max-width: 800px; |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.app-header { |
||||
text-align: center; |
||||
margin-bottom: 32px; |
||||
} |
||||
|
||||
.app-header h1 { |
||||
margin: 0 0 8px 0; |
||||
font-size: 32px; |
||||
font-weight: 500; |
||||
color: #030303; |
||||
} |
||||
|
||||
.app-header p { |
||||
margin: 0; |
||||
font-size: 16px; |
||||
color: #606060; |
||||
} |
||||
|
||||
.back-to-groups-button { |
||||
margin-bottom: 16px; |
||||
padding: 8px 16px; |
||||
background: transparent; |
||||
border: 1px solid #e5e5e5; |
||||
border-radius: 6px; |
||||
color: #030303; |
||||
font-size: 14px; |
||||
cursor: pointer; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.back-to-groups-button:hover { |
||||
background-color: #f5f5f5; |
||||
} |
||||
|
||||
.groups-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); |
||||
gap: 24px; |
||||
margin-top: 32px; |
||||
} |
||||
|
||||
.group-card { |
||||
background: white; |
||||
border: 2px solid #e5e5e5; |
||||
border-radius: 12px; |
||||
padding: 32px 24px; |
||||
text-align: center; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
text-decoration: none; |
||||
color: #030303; |
||||
} |
||||
|
||||
.group-card:hover { |
||||
border-color: #065fd4; |
||||
transform: translateY(-4px); |
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.group-card-name { |
||||
margin: 0 0 8px 0; |
||||
font-size: 24px; |
||||
font-weight: 600; |
||||
color: #030303; |
||||
} |
||||
|
||||
.group-card-count { |
||||
margin: 0; |
||||
font-size: 16px; |
||||
color: #606060; |
||||
} |
||||
|
||||
.practice-area { |
||||
background: white; |
||||
border-radius: 12px; |
||||
padding: 32px; |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.word-display { |
||||
text-align: center; |
||||
margin-bottom: 32px; |
||||
} |
||||
|
||||
.word-text { |
||||
font-size: 64px; |
||||
font-weight: 700; |
||||
color: #030303; |
||||
margin: 0 0 16px 0; |
||||
letter-spacing: 2px; |
||||
text-transform: uppercase; |
||||
} |
||||
|
||||
.practice-stats { |
||||
display: flex; |
||||
justify-content: center; |
||||
gap: 24px; |
||||
margin-top: 16px; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.stat-item { |
||||
font-size: 16px; |
||||
font-weight: 500; |
||||
padding: 8px 16px; |
||||
border-radius: 20px; |
||||
background: #f5f5f5; |
||||
} |
||||
|
||||
.stat-pass { |
||||
color: #166534; |
||||
background: #f0fdf4; |
||||
} |
||||
|
||||
.stat-fail { |
||||
color: #991b1b; |
||||
background: #fef2f2; |
||||
} |
||||
|
||||
.stat-total { |
||||
color: #030303; |
||||
} |
||||
|
||||
.practice-container { |
||||
margin-bottom: 32px; |
||||
} |
||||
|
||||
.practice-label { |
||||
text-align: center; |
||||
font-size: 16px; |
||||
font-weight: 500; |
||||
color: #030303; |
||||
margin-bottom: 20px; |
||||
} |
||||
|
||||
.practice-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(5, 1fr); |
||||
gap: 16px; |
||||
max-width: 645px; |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.practice-item { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
gap: 12px; |
||||
padding: 16px; |
||||
border: 2px solid #e5e5e5; |
||||
border-radius: 8px; |
||||
background: white; |
||||
} |
||||
|
||||
.practice-number { |
||||
font-size: 14px; |
||||
font-weight: 600; |
||||
color: #606060; |
||||
width: 32px; |
||||
height: 32px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
border-radius: 50%; |
||||
background: #f5f5f5; |
||||
} |
||||
|
||||
.practice-buttons { |
||||
display: flex; |
||||
gap: 8px; |
||||
} |
||||
|
||||
.practice-button { |
||||
width: 36px; |
||||
height: 36px; |
||||
border: 2px solid #e5e5e5; |
||||
border-radius: 6px; |
||||
background: white; |
||||
font-size: 20px; |
||||
font-weight: bold; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.pass-button { |
||||
color: #166534; |
||||
border-color: #86efac; |
||||
} |
||||
|
||||
.pass-button:hover { |
||||
background: #f0fdf4; |
||||
border-color: #4ade80; |
||||
} |
||||
|
||||
.pass-button.active { |
||||
background: #22c55e; |
||||
color: white; |
||||
border-color: #22c55e; |
||||
} |
||||
|
||||
.fail-button { |
||||
color: #991b1b; |
||||
border-color: #fecaca; |
||||
} |
||||
|
||||
.fail-button:hover { |
||||
background: #fef2f2; |
||||
border-color: #f87171; |
||||
} |
||||
|
||||
.fail-button.active { |
||||
background: #dc2626; |
||||
color: white; |
||||
border-color: #dc2626; |
||||
} |
||||
|
||||
.word-navigation { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 24px; |
||||
padding-top: 24px; |
||||
border-top: 1px solid #e5e5e5; |
||||
} |
||||
|
||||
.nav-button { |
||||
padding: 12px 24px; |
||||
background: #065fd4; |
||||
color: white; |
||||
border: none; |
||||
border-radius: 8px; |
||||
font-size: 16px; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.nav-button:hover:not(:disabled) { |
||||
background: #0556c4; |
||||
} |
||||
|
||||
.nav-button:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.word-counter { |
||||
font-size: 16px; |
||||
color: #606060; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.word-actions { |
||||
text-align: center; |
||||
} |
||||
|
||||
.reset-button { |
||||
padding: 10px 20px; |
||||
background: transparent; |
||||
color: #d00; |
||||
border: 1px solid #d00; |
||||
border-radius: 8px; |
||||
font-size: 14px; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.reset-button:hover { |
||||
background: #d00; |
||||
color: white; |
||||
} |
||||
|
||||
.loading-state, |
||||
.error-state, |
||||
.empty-state { |
||||
text-align: center; |
||||
padding: 48px 24px; |
||||
background: white; |
||||
border-radius: 12px; |
||||
margin-top: 32px; |
||||
} |
||||
|
||||
.empty-state h2 { |
||||
margin: 0 0 8px 0; |
||||
font-size: 24px; |
||||
color: #030303; |
||||
} |
||||
|
||||
.empty-state p { |
||||
margin: 0; |
||||
color: #606060; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.speech-sounds-app { |
||||
padding: 16px; |
||||
} |
||||
|
||||
.word-text { |
||||
font-size: 48px; |
||||
} |
||||
|
||||
.practice-grid { |
||||
grid-template-columns: repeat(3, 1fr); |
||||
gap: 12px; |
||||
} |
||||
|
||||
.practice-stats { |
||||
gap: 12px; |
||||
} |
||||
|
||||
.stat-item { |
||||
font-size: 14px; |
||||
padding: 6px 12px; |
||||
} |
||||
|
||||
.practice-area { |
||||
padding: 24px; |
||||
} |
||||
|
||||
.word-navigation { |
||||
flex-direction: column; |
||||
gap: 16px; |
||||
} |
||||
|
||||
.nav-button { |
||||
width: 100%; |
||||
} |
||||
} |
||||
@ -0,0 +1,283 @@
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect } from 'react'; |
||||
import { wordGroupsApi } from '../services/apiClient'; |
||||
import './SpeechSoundsApp.css'; |
||||
|
||||
interface Word { |
||||
id: number; |
||||
word: string; |
||||
} |
||||
|
||||
interface WordGroup { |
||||
id: number; |
||||
name: string; |
||||
words: Word[]; |
||||
} |
||||
|
||||
type PracticeResult = 'pass' | 'fail' | null; |
||||
|
||||
interface WordPractice { |
||||
[wordId: number]: PracticeResult[]; // Array of 10 practice results (pass/fail/null) for each word
|
||||
} |
||||
|
||||
const STORAGE_KEY = 'speech_sounds_practice'; |
||||
|
||||
export function SpeechSoundsApp() { |
||||
const [groups, setGroups] = useState<WordGroup[]>([]); |
||||
const [selectedGroup, setSelectedGroup] = useState<WordGroup | null>(null); |
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0); |
||||
const [practiceData, setPracticeData] = useState<WordPractice>({}); |
||||
const [loading, setLoading] = useState(true); |
||||
const [error, setError] = useState<string | null>(null); |
||||
const [showWordPractice, setShowWordPractice] = useState(false); |
||||
|
||||
// Load practice data from localStorage
|
||||
useEffect(() => { |
||||
try { |
||||
const stored = localStorage.getItem(STORAGE_KEY); |
||||
if (stored) { |
||||
setPracticeData(JSON.parse(stored)); |
||||
} |
||||
} catch (e) { |
||||
console.warn('Failed to load practice data from localStorage', e); |
||||
} |
||||
}, []); |
||||
|
||||
// Save practice data to localStorage whenever it changes
|
||||
useEffect(() => { |
||||
try { |
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(practiceData)); |
||||
} catch (e) { |
||||
console.warn('Failed to save practice data to localStorage', e); |
||||
} |
||||
}, [practiceData]); |
||||
|
||||
// Fetch word groups
|
||||
useEffect(() => { |
||||
const fetchGroups = async () => { |
||||
try { |
||||
setLoading(true); |
||||
setError(null); |
||||
const response = await wordGroupsApi.getAll(); |
||||
const groupsData = response.data.groups.filter((g: WordGroup) => g.words.length > 0); |
||||
setGroups(groupsData); |
||||
} catch (err: any) { |
||||
setError(err.error?.message || 'Failed to load word groups'); |
||||
} finally { |
||||
setLoading(false); |
||||
} |
||||
}; |
||||
fetchGroups(); |
||||
}, []); |
||||
|
||||
const getPracticeForWord = (wordId: number): PracticeResult[] => { |
||||
return practiceData[wordId] || Array(10).fill(null); |
||||
}; |
||||
|
||||
const togglePractice = (wordId: number, index: number, result: 'pass' | 'fail') => { |
||||
setPracticeData(prev => { |
||||
const current = prev[wordId] || Array(10).fill(null); |
||||
const updated = [...current]; |
||||
// Toggle: if clicking the same result, clear it; otherwise set it
|
||||
updated[index] = updated[index] === result ? null : result; |
||||
return { |
||||
...prev, |
||||
[wordId]: updated |
||||
}; |
||||
}); |
||||
}; |
||||
|
||||
const resetWord = (wordId: number) => { |
||||
if (confirm('Reset all practice for this word?')) { |
||||
setPracticeData(prev => { |
||||
const updated = { ...prev }; |
||||
updated[wordId] = Array(10).fill(null); |
||||
return updated; |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
const currentWord = selectedGroup?.words[currentWordIndex] || null; |
||||
const practiceResults = currentWord ? getPracticeForWord(currentWord.id) : []; |
||||
const passCount = practiceResults.filter(r => r === 'pass').length; |
||||
const failCount = practiceResults.filter(r => r === 'fail').length; |
||||
const totalCount = passCount + failCount; |
||||
|
||||
const handlePreviousWord = () => { |
||||
if (selectedGroup && currentWordIndex > 0) { |
||||
setCurrentWordIndex(currentWordIndex - 1); |
||||
} |
||||
}; |
||||
|
||||
const handleNextWord = () => { |
||||
if (selectedGroup && currentWordIndex < selectedGroup.words.length - 1) { |
||||
setCurrentWordIndex(currentWordIndex + 1); |
||||
} |
||||
}; |
||||
|
||||
const handleSelectGroup = (group: WordGroup) => { |
||||
setSelectedGroup(group); |
||||
setCurrentWordIndex(0); |
||||
setShowWordPractice(true); |
||||
}; |
||||
|
||||
const handleBackToGroups = () => { |
||||
setShowWordPractice(false); |
||||
setSelectedGroup(null); |
||||
setCurrentWordIndex(0); |
||||
}; |
||||
|
||||
if (loading) { |
||||
return ( |
||||
<div className="speech-sounds-app"> |
||||
<div className="loading-state"> |
||||
<p>Loading word groups...</p> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (error) { |
||||
return ( |
||||
<div className="speech-sounds-app"> |
||||
<div className="error-state"> |
||||
<p>Error: {error}</p> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (groups.length === 0) { |
||||
return ( |
||||
<div className="speech-sounds-app"> |
||||
<div className="empty-state"> |
||||
<h2>No Word Groups Available</h2> |
||||
<p>Please add word groups in the admin panel.</p> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Show word practice screen if group is selected
|
||||
if (showWordPractice && selectedGroup && currentWord) { |
||||
return ( |
||||
<div className="speech-sounds-app"> |
||||
<div className="app-header"> |
||||
<button onClick={handleBackToGroups} className="back-to-groups-button"> |
||||
← Back to Groups |
||||
</button> |
||||
<h1>{selectedGroup.name}</h1> |
||||
<p>Practice your speech sounds by checking off each time you say the word</p> |
||||
</div> |
||||
|
||||
<div className="practice-area"> |
||||
<div className="word-display"> |
||||
<h2 className="word-text">{currentWord.word}</h2> |
||||
<div className="practice-stats"> |
||||
<span className="stat-item stat-pass"> |
||||
✓ {passCount} Pass |
||||
</span> |
||||
<span className="stat-item stat-fail"> |
||||
✗ {failCount} Fail |
||||
</span> |
||||
<span className="stat-item stat-total"> |
||||
{totalCount} / 10 Total |
||||
</span> |
||||
</div> |
||||
</div> |
||||
|
||||
<div className="practice-container"> |
||||
<div className="practice-label">Mark each practice attempt:</div> |
||||
<div className="practice-grid"> |
||||
{Array.from({ length: 10 }, (_, i) => { |
||||
const result = practiceResults[i]; |
||||
const isPass = result === 'pass'; |
||||
const isFail = result === 'fail'; |
||||
|
||||
return ( |
||||
<div key={i} className="practice-item"> |
||||
<span className="practice-number">{i + 1}</span> |
||||
<div className="practice-buttons"> |
||||
<button |
||||
onClick={() => togglePractice(currentWord.id, i, 'pass')} |
||||
className={`practice-button pass-button ${isPass ? 'active' : ''}`} |
||||
title="Mark as pass" |
||||
> |
||||
✓ |
||||
</button> |
||||
<button |
||||
onClick={() => togglePractice(currentWord.id, i, 'fail')} |
||||
className={`practice-button fail-button ${isFail ? 'active' : ''}`} |
||||
title="Mark as fail" |
||||
> |
||||
✗ |
||||
</button> |
||||
</div> |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
</div> |
||||
|
||||
<div className="word-navigation"> |
||||
<button |
||||
onClick={handlePreviousWord} |
||||
disabled={currentWordIndex === 0} |
||||
className="nav-button prev-button" |
||||
> |
||||
← Previous |
||||
</button> |
||||
<span className="word-counter"> |
||||
Word {currentWordIndex + 1} of {selectedGroup.words.length} |
||||
</span> |
||||
<button |
||||
onClick={handleNextWord} |
||||
disabled={currentWordIndex === selectedGroup.words.length - 1} |
||||
className="nav-button next-button" |
||||
> |
||||
Next → |
||||
</button> |
||||
</div> |
||||
|
||||
<div className="word-actions"> |
||||
<button |
||||
onClick={() => resetWord(currentWord.id)} |
||||
className="reset-button" |
||||
> |
||||
Reset This Word |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Show group selection screen
|
||||
return ( |
||||
<div className="speech-sounds-app"> |
||||
<div className="app-header"> |
||||
<h1>Speech Sounds Practice</h1> |
||||
<p>Choose a word group to start practicing</p> |
||||
</div> |
||||
|
||||
{groups.length === 0 ? ( |
||||
<div className="empty-state"> |
||||
<h2>No Word Groups Available</h2> |
||||
<p>Please add word groups in the admin panel.</p> |
||||
</div> |
||||
) : ( |
||||
<div className="groups-grid"> |
||||
{groups.map(group => ( |
||||
<button |
||||
key={group.id} |
||||
onClick={() => handleSelectGroup(group)} |
||||
className="group-card" |
||||
> |
||||
<h3 className="group-card-name">{group.name}</h3> |
||||
<p className="group-card-count">{group.words.length} words</p> |
||||
</button> |
||||
))} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
import { Link } from 'react-router-dom'; |
||||
import { ChannelManager } from '../components/ChannelManager/ChannelManager'; |
||||
import { TimeLimitManager } from '../components/TimeLimitManager/TimeLimitManager'; |
||||
import './AdminPage.css'; |
||||
|
||||
export function VideosAdminPage() { |
||||
return ( |
||||
<div className="admin-page"> |
||||
<div className="admin-header"> |
||||
<Link to="/admin" className="back-button"> |
||||
← Back to Admin |
||||
</Link> |
||||
<h1>Video App Settings</h1> |
||||
<p>Manage YouTube channels and video time limits</p> |
||||
</div> |
||||
|
||||
<div className="admin-content"> |
||||
<div className="admin-column"> |
||||
<ChannelManager /> |
||||
</div> |
||||
<div className="admin-column"> |
||||
<TimeLimitManager /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
Loading…
Reference in new issue