15 changed files with 1866 additions and 16 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
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