From 3d10a76f89452de09160373ed9719e4a68f72e0b Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sun, 7 Dec 2025 21:10:26 -0800 Subject: [PATCH] working speech sounds app --- .../src/controllers/wordGroups.controller.ts | 266 +++++++++++++ backend/src/db/migrate.ts | 29 ++ backend/src/index.ts | 2 + backend/src/routes/wordGroups.routes.ts | 30 ++ frontend/src/App.tsx | 18 + .../WordGroupManager/WordGroupManager.css | 370 ++++++++++++++++++ .../WordGroupManager/WordGroupManager.tsx | 324 +++++++++++++++ frontend/src/config/apps.ts | 14 +- frontend/src/pages/AdminPage.css | 116 ++++++ frontend/src/pages/AdminPage.tsx | 24 +- frontend/src/pages/SpeechSoundsAdminPage.tsx | 18 + frontend/src/pages/SpeechSoundsApp.css | 341 ++++++++++++++++ frontend/src/pages/SpeechSoundsApp.tsx | 283 ++++++++++++++ frontend/src/pages/VideosAdminPage.tsx | 27 ++ frontend/src/services/apiClient.ts | 20 + 15 files changed, 1866 insertions(+), 16 deletions(-) create mode 100644 backend/src/controllers/wordGroups.controller.ts create mode 100644 backend/src/routes/wordGroups.routes.ts create mode 100644 frontend/src/components/WordGroupManager/WordGroupManager.css create mode 100644 frontend/src/components/WordGroupManager/WordGroupManager.tsx create mode 100644 frontend/src/pages/SpeechSoundsAdminPage.tsx create mode 100644 frontend/src/pages/SpeechSoundsApp.css create mode 100644 frontend/src/pages/SpeechSoundsApp.tsx create mode 100644 frontend/src/pages/VideosAdminPage.tsx diff --git a/backend/src/controllers/wordGroups.controller.ts b/backend/src/controllers/wordGroups.controller.ts new file mode 100644 index 0000000..9b4786a --- /dev/null +++ b/backend/src/controllers/wordGroups.controller.ts @@ -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' + } + }); + } +} diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 93a43be..12383ec 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -135,6 +135,35 @@ const migrations = [ ON videos_cache(duration_seconds) `); } + }, + { + id: 3, + name: 'add_speech_sounds_tables', + up: async () => { + // Create word_groups table + await db.execute(` + CREATE TABLE IF NOT EXISTS word_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await db.execute('CREATE INDEX IF NOT EXISTS idx_word_groups_name ON word_groups(name)'); + + // Create words table + await db.execute(` + CREATE TABLE IF NOT EXISTS words ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + word_group_id INTEGER NOT NULL, + word TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (word_group_id) REFERENCES word_groups(id) ON DELETE CASCADE + ) + `); + await db.execute('CREATE INDEX IF NOT EXISTS idx_words_group_id ON words(word_group_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_words_word ON words(word)'); + } } ]; diff --git a/backend/src/index.ts b/backend/src/index.ts index b0bac0a..39e4a53 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import authRoutes from './routes/auth.routes.js'; import channelRoutes from './routes/channels.routes.js'; import videoRoutes from './routes/videos.routes.js'; import settingsRoutes from './routes/settings.routes.js'; +import wordGroupsRoutes from './routes/wordGroups.routes.js'; import { errorHandler } from './middleware/errorHandler.js'; import { apiLimiter } from './middleware/rateLimiter.js'; @@ -46,6 +47,7 @@ async function startServer() { app.use('/api/channels', channelRoutes); app.use('/api/videos', videoRoutes); app.use('/api/settings', settingsRoutes); + app.use('/api/word-groups', wordGroupsRoutes); // Error handling app.use(errorHandler); diff --git a/backend/src/routes/wordGroups.routes.ts b/backend/src/routes/wordGroups.routes.ts new file mode 100644 index 0000000..5689781 --- /dev/null +++ b/backend/src/routes/wordGroups.routes.ts @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff422a3..006bbd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { Navbar } from './components/Navbar/Navbar'; import { ProtectedRoute } from './components/ProtectedRoute'; import { LandingPage } from './pages/LandingPage'; import { AdminPage } from './pages/AdminPage'; +import { VideosAdminPage } from './pages/VideosAdminPage'; +import { SpeechSoundsAdminPage } from './pages/SpeechSoundsAdminPage'; import { LoginPage } from './pages/LoginPage'; import { APPS } from './config/apps'; import './App.css'; @@ -37,6 +39,22 @@ function App() { } /> + + + + } + /> + + + + } + /> diff --git a/frontend/src/components/WordGroupManager/WordGroupManager.css b/frontend/src/components/WordGroupManager/WordGroupManager.css new file mode 100644 index 0000000..accef53 --- /dev/null +++ b/frontend/src/components/WordGroupManager/WordGroupManager.css @@ -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; + } +} diff --git a/frontend/src/components/WordGroupManager/WordGroupManager.tsx b/frontend/src/components/WordGroupManager/WordGroupManager.tsx new file mode 100644 index 0000000..9c8b9ac --- /dev/null +++ b/frontend/src/components/WordGroupManager/WordGroupManager.tsx @@ -0,0 +1,324 @@ +import { useState, useEffect } from 'react'; +import { wordGroupsApi } from '../../services/apiClient'; +import './WordGroupManager.css'; + +interface Word { + id: number; + word: string; +} + +interface WordGroup { + id: number; + name: string; + wordCount: number; + words: Word[]; + createdAt: string; + updatedAt: string; +} + +export function WordGroupManager() { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingGroupId, setEditingGroupId] = useState(null); + const [editingGroupName, setEditingGroupName] = useState(''); + const [newGroupName, setNewGroupName] = useState(''); + const [newWordInputs, setNewWordInputs] = useState>({}); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + useEffect(() => { + fetchGroups(); + }, []); + + const fetchGroups = async () => { + try { + setLoading(true); + setError(null); + const response = await wordGroupsApi.getAll(); + setGroups(response.data.groups); + } catch (err: any) { + setError(err.error?.message || 'Failed to load word groups'); + } finally { + setLoading(false); + } + }; + + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newGroupName.trim()) return; + + try { + setError(null); + const response = await wordGroupsApi.create(newGroupName.trim()); + setGroups([response.data.group, ...groups]); + setNewGroupName(''); + } catch (err: any) { + setError(err.error?.message || 'Failed to create word group'); + } + }; + + const handleUpdateGroup = async (groupId: number) => { + if (!editingGroupName.trim()) { + setEditingGroupId(null); + return; + } + + try { + setError(null); + await wordGroupsApi.update(groupId, editingGroupName.trim()); + setGroups(groups.map(g => + g.id === groupId + ? { ...g, name: editingGroupName.trim(), updatedAt: new Date().toISOString() } + : g + )); + setEditingGroupId(null); + setEditingGroupName(''); + } catch (err: any) { + setError(err.error?.message || 'Failed to update word group'); + } + }; + + const handleDeleteGroup = async (groupId: number, groupName: string) => { + if (!confirm(`Are you sure you want to delete "${groupName}"? This will also delete all words in this group.`)) { + return; + } + + try { + setError(null); + await wordGroupsApi.delete(groupId); + setGroups(groups.filter(g => g.id !== groupId)); + setExpandedGroups(prev => { + const next = new Set(prev); + next.delete(groupId); + return next; + }); + } catch (err: any) { + setError(err.error?.message || 'Failed to delete word group'); + } + }; + + const handleAddWord = async (groupId: number) => { + const word = newWordInputs[groupId]?.trim(); + if (!word) return; + + try { + setError(null); + const response = await wordGroupsApi.addWord(groupId, word); + setGroups(groups.map(g => + g.id === groupId + ? { + ...g, + words: [...g.words, response.data.word].sort((a, b) => a.word.localeCompare(b.word)), + wordCount: g.wordCount + 1 + } + : g + )); + setNewWordInputs({ ...newWordInputs, [groupId]: '' }); + } catch (err: any) { + setError(err.error?.message || 'Failed to add word'); + } + }; + + const handleDeleteWord = async (wordId: number, groupId: number, word: string) => { + if (!confirm(`Delete "${word}"?`)) return; + + try { + setError(null); + await wordGroupsApi.deleteWord(wordId); + setGroups(groups.map(g => + g.id === groupId + ? { + ...g, + words: g.words.filter(w => w.id !== wordId), + wordCount: g.wordCount - 1 + } + : g + )); + } catch (err: any) { + setError(err.error?.message || 'Failed to delete word'); + } + }; + + const toggleGroup = (groupId: number) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + const startEditing = (group: WordGroup) => { + setEditingGroupId(group.id); + setEditingGroupName(group.name); + }; + + const cancelEditing = () => { + setEditingGroupId(null); + setEditingGroupName(''); + }; + + if (loading) { + return ( +
+
+

Speech Sounds - Word Groups

+

Loading...

+
+
+ ); + } + + return ( +
+
+

Speech Sounds - Word Groups

+

Create groups of words to help practice speech sounds

+
+ + {error && ( +
{error}
+ )} + +
+ setNewGroupName(e.target.value)} + className="group-name-input" + /> + +
+ + {groups.length === 0 ? ( +
+

No word groups yet. Create your first group above!

+
+ ) : ( +
+ {groups.map(group => ( +
+
+ {editingGroupId === group.id ? ( +
+ setEditingGroupName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleUpdateGroup(group.id); + } else if (e.key === 'Escape') { + cancelEditing(); + } + }} + className="edit-group-input" + autoFocus + /> + + +
+ ) : ( + <> +
+

toggleGroup(group.id)}> + {group.name} + ({group.wordCount} words) +

+
+ + +
+
+ + )} +
+ + {expandedGroups.has(group.id) && ( +
+
+ {group.words.length === 0 ? ( +

No words yet. Add words below.

+ ) : ( +
+ {group.words.map(word => ( +
+ {word.word} + +
+ ))} +
+ )} +
+ +
{ + e.preventDefault(); + handleAddWord(group.id); + }} + className="add-word-form" + > + setNewWordInputs({ ...newWordInputs, [group.id]: e.target.value })} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddWord(group.id); + } + }} + className="word-input" + /> + +
+
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 57d6324..034a2c3 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -1,5 +1,6 @@ import React from 'react'; import { VideoApp } from '../pages/VideoApp'; +import { SpeechSoundsApp } from '../pages/SpeechSoundsApp'; export type App = { id: string; @@ -22,11 +23,12 @@ export const APPS: App[] = [ component: VideoApp }, { - id: 'storytime', - name: 'Story Time (Coming Soon)', - description: 'Narrated stories and audio adventures for quiet time.', - cta: 'In Development', - link: '/stories', - disabled: true + id: 'speechsounds', + name: 'Speech Sounds', + description: 'Practice speech sounds with word groups and track your progress.', + cta: 'Start Practicing', + link: '/speech-sounds', + disabled: false, + component: SpeechSoundsApp } ]; diff --git a/frontend/src/pages/AdminPage.css b/frontend/src/pages/AdminPage.css index 840920b..fb32910 100644 --- a/frontend/src/pages/AdminPage.css +++ b/frontend/src/pages/AdminPage.css @@ -23,6 +23,69 @@ color: #606060; } +.back-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-button:hover { + background-color: #f5f5f5; +} + +.admin-section-link { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #e5e5e5; +} + +.section-link-button { + display: block; + width: 100%; + padding: 12px 20px; + background: linear-gradient(135deg, #4a90e2, #357abd); + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + text-align: center; + text-decoration: none; +} + +.section-link-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3); + color: white; +} + +.back-button { + display: inline-block; + 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; + text-decoration: none; +} + +.back-button:hover { + background-color: #f5f5f5; + color: #030303; +} + .admin-content { display: grid; grid-template-columns: 1fr 1fr; @@ -37,10 +100,63 @@ flex-direction: column; } +.admin-links-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.admin-link-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 32px 24px; + background: white; + border: 1px solid #e5e5e5; + border-radius: 12px; + text-decoration: none; + color: #030303; + transition: transform 0.2s, box-shadow 0.2s; +} + +.admin-link-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + color: #030303; +} + +.admin-link-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.admin-link-card h2 { + margin: 0 0 8px 0; + font-size: 24px; + font-weight: 500; + color: #030303; +} + +.admin-link-card p { + margin: 0; + font-size: 14px; + color: #606060; + line-height: 1.5; +} + @media (max-width: 1024px) { .admin-content { grid-template-columns: 1fr; } + + .admin-links-grid { + grid-template-columns: 1fr; + padding: 16px; + } } diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2c3e0ff..f794baf 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,5 +1,4 @@ -import { ChannelManager } from '../components/ChannelManager/ChannelManager'; -import { TimeLimitManager } from '../components/TimeLimitManager/TimeLimitManager'; +import { Link } from 'react-router-dom'; import './AdminPage.css'; export function AdminPage() { @@ -7,16 +6,21 @@ export function AdminPage() {

Admin Dashboard

-

Manage YouTube channels and video settings

+

Manage app settings and configurations

-
-
- -
-
- -
+
+ +
📹
+

Video App

+

Manage YouTube channels and video time limits

+ + + +
🗣️
+

Speech Sounds

+

Manage word groups for speech sound practice

+
); diff --git a/frontend/src/pages/SpeechSoundsAdminPage.tsx b/frontend/src/pages/SpeechSoundsAdminPage.tsx new file mode 100644 index 0000000..100918d --- /dev/null +++ b/frontend/src/pages/SpeechSoundsAdminPage.tsx @@ -0,0 +1,18 @@ +import { WordGroupManager } from '../components/WordGroupManager/WordGroupManager'; +import { Link } from 'react-router-dom'; +import './AdminPage.css'; + +export function SpeechSoundsAdminPage() { + return ( +
+
+ + ← Back to Admin + +

Speech Sounds - Word Groups

+

Manage word groups for speech sound practice

+
+ +
+ ); +} diff --git a/frontend/src/pages/SpeechSoundsApp.css b/frontend/src/pages/SpeechSoundsApp.css new file mode 100644 index 0000000..e0cdca2 --- /dev/null +++ b/frontend/src/pages/SpeechSoundsApp.css @@ -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%; + } +} diff --git a/frontend/src/pages/SpeechSoundsApp.tsx b/frontend/src/pages/SpeechSoundsApp.tsx new file mode 100644 index 0000000..bd239c5 --- /dev/null +++ b/frontend/src/pages/SpeechSoundsApp.tsx @@ -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([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [currentWordIndex, setCurrentWordIndex] = useState(0); + const [practiceData, setPracticeData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+

Loading word groups...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error: {error}

+
+
+ ); + } + + if (groups.length === 0) { + return ( +
+
+

No Word Groups Available

+

Please add word groups in the admin panel.

+
+
+ ); + } + + // Show word practice screen if group is selected + if (showWordPractice && selectedGroup && currentWord) { + return ( +
+
+ +

{selectedGroup.name}

+

Practice your speech sounds by checking off each time you say the word

+
+ +
+
+

{currentWord.word}

+
+ + ✓ {passCount} Pass + + + ✗ {failCount} Fail + + + {totalCount} / 10 Total + +
+
+ +
+
Mark each practice attempt:
+
+ {Array.from({ length: 10 }, (_, i) => { + const result = practiceResults[i]; + const isPass = result === 'pass'; + const isFail = result === 'fail'; + + return ( +
+ {i + 1} +
+ + +
+
+ ); + })} +
+
+ +
+ + + Word {currentWordIndex + 1} of {selectedGroup.words.length} + + +
+ +
+ +
+
+
+ ); + } + + // Show group selection screen + return ( +
+
+

Speech Sounds Practice

+

Choose a word group to start practicing

+
+ + {groups.length === 0 ? ( +
+

No Word Groups Available

+

Please add word groups in the admin panel.

+
+ ) : ( +
+ {groups.map(group => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/VideosAdminPage.tsx b/frontend/src/pages/VideosAdminPage.tsx new file mode 100644 index 0000000..bbb3c72 --- /dev/null +++ b/frontend/src/pages/VideosAdminPage.tsx @@ -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 ( +
+
+ + ← Back to Admin + +

Video App Settings

+

Manage YouTube channels and video time limits

+
+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index d54145b..a1af4c2 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -127,5 +127,25 @@ export const settingsApi = { api.put('/settings/time-limit', { dailyLimit }) }; +// Word Groups API +export const wordGroupsApi = { + getAll: () => api.get('/word-groups'), + + create: (name: string) => + api.post('/word-groups', { name }), + + update: (id: number, name: string) => + api.put(`/word-groups/${id}`, { name }), + + delete: (id: number) => + api.delete(`/word-groups/${id}`), + + addWord: (groupId: number, word: string) => + api.post(`/word-groups/${groupId}/words`, { word }), + + deleteWord: (wordId: number) => + api.delete(`/word-groups/words/${wordId}`) +}; +