Browse Source

working speech sounds app

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
3d10a76f89
  1. 266
      backend/src/controllers/wordGroups.controller.ts
  2. 29
      backend/src/db/migrate.ts
  3. 2
      backend/src/index.ts
  4. 30
      backend/src/routes/wordGroups.routes.ts
  5. 18
      frontend/src/App.tsx
  6. 370
      frontend/src/components/WordGroupManager/WordGroupManager.css
  7. 324
      frontend/src/components/WordGroupManager/WordGroupManager.tsx
  8. 14
      frontend/src/config/apps.ts
  9. 116
      frontend/src/pages/AdminPage.css
  10. 24
      frontend/src/pages/AdminPage.tsx
  11. 18
      frontend/src/pages/SpeechSoundsAdminPage.tsx
  12. 341
      frontend/src/pages/SpeechSoundsApp.css
  13. 283
      frontend/src/pages/SpeechSoundsApp.tsx
  14. 27
      frontend/src/pages/VideosAdminPage.tsx
  15. 20
      frontend/src/services/apiClient.ts

266
backend/src/controllers/wordGroups.controller.ts

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

29
backend/src/db/migrate.ts

@ -135,6 +135,35 @@ const migrations = [ @@ -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)');
}
}
];

2
backend/src/index.ts

@ -8,6 +8,7 @@ import authRoutes from './routes/auth.routes.js'; @@ -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() { @@ -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);

30
backend/src/routes/wordGroups.routes.ts

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

18
frontend/src/App.tsx

@ -5,6 +5,8 @@ import { Navbar } from './components/Navbar/Navbar'; @@ -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() { @@ -37,6 +39,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin/videos"
element={
<ProtectedRoute>
<VideosAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/speech-sounds"
element={
<ProtectedRoute>
<SpeechSoundsAdminPage />
</ProtectedRoute>
}
/>
</Routes>
</main>
</div>

370
frontend/src/components/WordGroupManager/WordGroupManager.css

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

324
frontend/src/components/WordGroupManager/WordGroupManager.tsx

@ -0,0 +1,324 @@ @@ -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<WordGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [editingGroupName, setEditingGroupName] = useState('');
const [newGroupName, setNewGroupName] = useState('');
const [newWordInputs, setNewWordInputs] = useState<Record<number, string>>({});
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(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 (
<div className="word-group-manager">
<div className="word-group-header">
<h2>Speech Sounds - Word Groups</h2>
<p>Loading...</p>
</div>
</div>
);
}
return (
<div className="word-group-manager">
<div className="word-group-header">
<h2>Speech Sounds - Word Groups</h2>
<p>Create groups of words to help practice speech sounds</p>
</div>
{error && (
<div className="alert alert-error">{error}</div>
)}
<form onSubmit={handleCreateGroup} className="create-group-form">
<input
type="text"
placeholder="Enter group name (e.g., 'R Sounds', 'S Blends')..."
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
className="group-name-input"
/>
<button type="submit" disabled={!newGroupName.trim()} className="create-group-btn">
Create Group
</button>
</form>
{groups.length === 0 ? (
<div className="empty-state">
<p>No word groups yet. Create your first group above!</p>
</div>
) : (
<div className="groups-list">
{groups.map(group => (
<div key={group.id} className="word-group-card">
<div className="group-header">
{editingGroupId === group.id ? (
<div className="edit-group-form">
<input
type="text"
value={editingGroupName}
onChange={(e) => setEditingGroupName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleUpdateGroup(group.id);
} else if (e.key === 'Escape') {
cancelEditing();
}
}}
className="edit-group-input"
autoFocus
/>
<button
onClick={() => handleUpdateGroup(group.id)}
className="save-btn"
>
Save
</button>
<button
onClick={cancelEditing}
className="cancel-btn"
>
Cancel
</button>
</div>
) : (
<>
<div className="group-info">
<h3 className="group-name" onClick={() => toggleGroup(group.id)}>
{group.name}
<span className="word-count">({group.wordCount} words)</span>
</h3>
<div className="group-actions">
<button
onClick={() => startEditing(group)}
className="edit-btn"
title="Edit group name"
>
</button>
<button
onClick={() => handleDeleteGroup(group.id, group.name)}
className="delete-btn"
title="Delete group"
>
🗑
</button>
</div>
</div>
</>
)}
</div>
{expandedGroups.has(group.id) && (
<div className="group-content">
<div className="words-list">
{group.words.length === 0 ? (
<p className="no-words">No words yet. Add words below.</p>
) : (
<div className="words-grid">
{group.words.map(word => (
<div key={word.id} className="word-item">
<span className="word-text">{word.word}</span>
<button
onClick={() => handleDeleteWord(word.id, group.id, word.word)}
className="delete-word-btn"
title="Delete word"
>
×
</button>
</div>
))}
</div>
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
handleAddWord(group.id);
}}
className="add-word-form"
>
<input
type="text"
placeholder="Enter a word..."
value={newWordInputs[group.id] || ''}
onChange={(e) => setNewWordInputs({ ...newWordInputs, [group.id]: e.target.value })}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddWord(group.id);
}
}}
className="word-input"
/>
<button
type="submit"
disabled={!newWordInputs[group.id]?.trim()}
className="add-word-btn"
>
Add Word
</button>
</form>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

14
frontend/src/config/apps.ts

@ -1,5 +1,6 @@ @@ -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[] = [ @@ -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
}
];

116
frontend/src/pages/AdminPage.css

@ -23,6 +23,69 @@ @@ -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 @@ @@ -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;
}
}

24
frontend/src/pages/AdminPage.tsx

@ -1,5 +1,4 @@ @@ -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() { @@ -7,16 +6,21 @@ export function AdminPage() {
<div className="admin-page">
<div className="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage YouTube channels and video settings</p>
<p>Manage app settings and configurations</p>
</div>
<div className="admin-content">
<div className="admin-column">
<ChannelManager />
</div>
<div className="admin-column">
<TimeLimitManager />
</div>
<div className="admin-links-grid">
<Link to="/admin/videos" className="admin-link-card">
<div className="admin-link-icon">📹</div>
<h2>Video App</h2>
<p>Manage YouTube channels and video time limits</p>
</Link>
<Link to="/admin/speech-sounds" className="admin-link-card">
<div className="admin-link-icon">🗣</div>
<h2>Speech Sounds</h2>
<p>Manage word groups for speech sound practice</p>
</Link>
</div>
</div>
);

18
frontend/src/pages/SpeechSoundsAdminPage.tsx

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

341
frontend/src/pages/SpeechSoundsApp.css

@ -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%;
}
}

283
frontend/src/pages/SpeechSoundsApp.tsx

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

27
frontend/src/pages/VideosAdminPage.tsx

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

20
frontend/src/services/apiClient.ts

@ -127,5 +127,25 @@ export const settingsApi = { @@ -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}`)
};

Loading…
Cancel
Save