From 17b1d6bbd45d177fba2efd69a6189460bce6062f Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 16:05:50 +0500 Subject: [PATCH 1/5] admin panel --- frontend/package-lock.json | 54 ------------- frontend/src/pages/Home.jsx | 5 +- frontend/src/pages/admin/AdminCategories.jsx | 78 +++++++++++++++---- frontend/src/pages/admin/AdminDashboard.jsx | 20 ++++- frontend/src/pages/admin/AdminLayout.jsx | 2 +- frontend/src/pages/admin/AdminVideos.jsx | 79 +++++++++++++++++--- frontend/src/services/api.js | 8 +- frontend/src/styles/admin.css | 14 ++++ 8 files changed, 169 insertions(+), 91 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 361ba56..66db6b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -666,9 +666,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -685,9 +682,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -704,9 +698,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +714,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -742,9 +730,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -761,9 +746,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -978,9 +960,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1001,9 +980,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1024,9 +1000,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1047,9 +1020,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1213,9 +1183,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1232,9 +1199,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1251,9 +1215,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1270,9 +1231,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3221,9 +3179,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3244,9 +3199,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3267,9 +3219,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3290,9 +3239,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 8942cb2..d2718c7 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,8 +1,8 @@ import { Link } from 'react-router-dom' -import { Avatar, Eyebrow, ProgressBar, SectionHeading } from '../components/ui' +import { Eyebrow, ProgressBar, SectionHeading } from '../components/ui' import { useAuth } from '../context/AuthContext' import { useLibrary } from '../context/LibraryContext' -import { categoryUrl, paths, watchUrl } from '../routes/appRoutes' +import { categoryUrl, watchUrl } from '../routes/appRoutes' import { greeting } from '../utils/format' export default function Home() { @@ -20,7 +20,6 @@ export default function Home() {

{greeting()}, {user.firstName}.

Which video would you like to contribute to today?

- {user.firstName} {user.surname}
diff --git a/frontend/src/pages/admin/AdminCategories.jsx b/frontend/src/pages/admin/AdminCategories.jsx index d85a355..4d6649e 100644 --- a/frontend/src/pages/admin/AdminCategories.jsx +++ b/frontend/src/pages/admin/AdminCategories.jsx @@ -8,19 +8,26 @@ import { formatTime } from '../../utils/format' const NEW_CATEGORY = { label: '', copy: '', tone: TONES[0] } export default function AdminCategories() { - const { categories, videos, addCategory } = useLibrary() + const { categories, videos, submissions, addCategory } = useLibrary() const [selectedId, setSelectedId] = useState(categories[0]?.id || null) - const [form, setForm] = useState(NEW_CATEGORY) + const [form, setForm] = useState({ ...NEW_CATEGORY, archived: false }) const [error, setError] = useState('') - const selected = categories.find((item) => item.id === selectedId) || null - const change = (event) => setForm((current) => ({ ...current, [event.target.name]: event.target.value })) + + const orderedCategories = [...categories].sort((a, b) => (a.order || 0) - (b.order || 0)) + const activeCategories = orderedCategories.filter((item) => !item.archived) + const archivedCategories = orderedCategories.filter((item) => item.archived) + const selected = orderedCategories.find((item) => item.id === selectedId) || null + const change = (event) => { + const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value + setForm((current) => ({ ...current, [event.target.name]: value })) + } const create = async (event) => { event.preventDefault() if (!form.label.trim()) { setError('Give the category a name.'); return } if (categories.some((item) => item.label.toLowerCase() === form.label.trim().toLowerCase())) { setError('A category with that name already exists.'); return } - const created = await addCategory(form) - setForm(NEW_CATEGORY) + const created = await addCategory({ ...form, archived: !!form.archived }) + setForm({ ...NEW_CATEGORY, archived: false }) setError('') setSelectedId(created.id) } @@ -33,13 +40,25 @@ export default function AdminCategories() { {TONES.map((tone) => )} + +
    - {categories.map((category) =>
  • )} + {activeCategories.length ? activeCategories.map((category) =>
  • ) :
  • No active categories yet.
  • }
+ + {archivedCategories.length > 0 &&
+

Archived

+
    + {archivedCategories.map((category) =>
  • )} +
+
} {selected ? setSelectedId(null)} /> :

Select or create a category to manage its videos.

} @@ -47,28 +66,57 @@ export default function AdminCategories() { } function CategoryDetail({ category, onDeleted }) { - const { categories, videos, editCategory, deleteCategory, editVideo } = useLibrary() - const [draft, setDraft] = useState({ label: category.label, copy: category.copy, tone: category.tone }) + const { categories, videos, submissions, editCategory, deleteCategory, editVideo } = useLibrary() + const [draft, setDraft] = useState({ label: category.label, copy: category.copy, tone: category.tone, archived: !!category.archived }) const [saved, setSaved] = useState(false) const [confirming, setConfirming] = useState(false) const assigned = videos.filter((video) => video.categoryId === category.id).sort((a, b) => (a.order || 0) - (b.order || 0)) const others = videos.filter((video) => video.categoryId !== category.id) - const change = (event) => { setSaved(false); setDraft((current) => ({ ...current, [event.target.name]: event.target.value })) } + const change = (event) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; setSaved(false); setDraft((current) => ({ ...current, [event.target.name]: value })) } + + const categorySubmissionCount = submissions.filter((item) => assigned.some((video) => video.id === item.videoId)).length + const publishedCount = assigned.filter((video) => video.status === 'published').length - // Moving a video into a category puts it at the end of that path. - const assign = (video) => editVideo(video.id, { categoryId: category.id, order: Math.max(0, ...assigned.map((item) => item.order || 0)) + 1 }) + const assign = async (video) => { + const nextOrder = Math.max(0, ...assigned.map((item) => item.order || 0)) + 1 + await editVideo(video.id, { categoryId: category.id, order: nextOrder }) + } const unassign = (video) => editVideo(video.id, { categoryId: null }) + const moveCategoryOrder = async (direction) => { + const ordered = [...categories].sort((a, b) => (a.order || 0) - (b.order || 0)) + const index = ordered.findIndex((item) => item.id === category.id) + const targetIndex = index + direction + if (index < 0 || targetIndex < 0 || targetIndex >= ordered.length) return + const target = ordered[targetIndex] + const currentOrder = category.order || 0 + const nextOrder = target.order || 0 + await Promise.all([ + editCategory(category.id, { order: nextOrder }), + editCategory(target.id, { order: currentOrder }), + ]) + } + return
Edit category

{category.label}

+
+ {assigned.length} videos + {publishedCount} published + {categorySubmissionCount} recordings +
{TONES.map((tone) => )} + +
+
+ +
- + {confirming ? <> : } diff --git a/frontend/src/pages/admin/AdminDashboard.jsx b/frontend/src/pages/admin/AdminDashboard.jsx index e327c50..6b3801c 100644 --- a/frontend/src/pages/admin/AdminDashboard.jsx +++ b/frontend/src/pages/admin/AdminDashboard.jsx @@ -27,7 +27,17 @@ function ActivityChart({ counts, labels }) { export default function AdminDashboard() { const { categories, videos, submissions } = useLibrary() const [users, setUsers] = useState([]) - useEffect(() => { api.users.list().then(setUsers) }, []) + const [usersError, setUsersError] = useState('') + const [usersLoading, setUsersLoading] = useState(true) + + useEffect(() => { + let active = true + api.users.list() + .then((list) => { if (active) { setUsers(list); setUsersError('') } }) + .catch(() => { if (active) setUsersError('Unable to load contributor data right now. Try refreshing the page.') }) + .finally(() => { if (active) setUsersLoading(false) }) + return () => { active = false } + }, []) const contributors = users.filter((user) => user.role === 'user') const published = videos.filter((video) => video.status === 'published').length @@ -52,14 +62,16 @@ export default function AdminDashboard() { ] return <> + {usersError &&

{usersError}

} +
{stats.map(([label, value, note]) =>
{label}{value}{note}
)}
- Last {DAYS} days} /> - + Last {DAYS} days} /> + {usersLoading ?
: }
@@ -84,7 +96,7 @@ export default function AdminDashboard() { })}
- :

No submissions yet.

} + :

No submissions yet. As soon as contributors upload recordings, they will appear here.

}
} diff --git a/frontend/src/pages/admin/AdminLayout.jsx b/frontend/src/pages/admin/AdminLayout.jsx index c749dbe..c2dfc3d 100644 --- a/frontend/src/pages/admin/AdminLayout.jsx +++ b/frontend/src/pages/admin/AdminLayout.jsx @@ -11,7 +11,7 @@ export default function AdminLayout() { return
Admin workspace

{greeting()}, {user.firstName}.

- ← Profile + Profile
} + /> + + {selectedIds.length > 0 &&
+ setBulkCategoryId(event.target.value)}> + + {categories.map((item) => )} + + + +
} + + {bulkError && {bulkError}} + {shown.length - ?
    {shown.map((video) => )}
+ ?
    {shown.map((video) => )}
:

{videos.length ? 'No videos match that search.' : 'No videos yet. Upload the first one above.'}

}
@@ -62,8 +107,8 @@ function UploadPanel() { setMessage({ tone: 'success', text: `“${form.title.trim()}” uploaded${form.status === 'published' && form.categoryId ? ' and live for contributors.' : '. Assign a category and publish it to make it visible.'}` }) URL.revokeObjectURL(previewUrl) setFile(null); setPreviewUrl(''); setForm({ title: '', categoryId: '', level: LEVELS[0], status: 'published' }) - } catch { - setMessage({ tone: 'error', text: 'The upload failed. Check that your browser has storage space and try again.' }) + } catch (error) { + setMessage({ tone: 'error', text: error?.message || 'The upload failed. Check that your browser has storage space and try again.' }) } finally { setBusy(false) } } @@ -78,32 +123,43 @@ function UploadPanel() {
-
- {categories.map((item) => )} - {LEVELS.map((item) => )} -
+ {categories.map((item) => )} {message && {message.text}} + {!file &&

Select a file to enable the upload action.

}
} -function VideoRow({ video, categories }) { +function VideoRow({ video, videos, categories, selected, onToggleSelect }) { const { editVideo, deleteVideo } = useLibrary() const [mode, setMode] = useState('view') // view | edit | preview | delete const [draft, setDraft] = useState({ title: video.title, level: video.level, categoryId: video.categoryId || '', status: video.status }) const category = categories.find((item) => item.id === video.categoryId) const change = (event) => setDraft((current) => ({ ...current, [event.target.name]: event.target.value })) + + const moveToCategory = async (nextCategoryId) => { + const targetVideos = videos.filter((item) => item.categoryId === nextCategoryId && item.id !== video.id) + const order = nextCategoryId ? Math.max(0, ...targetVideos.map((item) => item.order || 0)) + 1 : video.order || 0 + await editVideo(video.id, { categoryId: nextCategoryId || null, order }) + } + const save = async () => { if (!draft.title.trim()) return - await editVideo(video.id, { ...draft, title: draft.title.trim(), categoryId: draft.categoryId || null }) + await editVideo(video.id, { + ...draft, + title: draft.title.trim(), + categoryId: draft.categoryId || null, + order: draft.categoryId ? Math.max(0, ...videos.filter((item) => item.categoryId === draft.categoryId && item.id !== video.id).map((item) => item.order || 0)) + 1 : video.order || 0, + }) setMode('view') } return
  • +
    {video.title}{category?.label || 'Unassigned'} · {video.level} · {formatTime(video.durationSec)}
    {video.status === 'published' ? 'Published' : 'Draft'} @@ -121,6 +177,7 @@ function VideoRow({ video, categories }) { {LEVELS.map((item) => )}
    +
    } {mode === 'delete' &&

    Delete “{video.title}”? Contributors will no longer see it. Their past submissions stay on record.

    diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 8dc57b2..c4fdc5c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -111,9 +111,11 @@ export const api = { categories: { async list() { return read('categories', []) }, - async create({ label, copy, tone }) { - const category = { id: uid('cat'), label: label.trim(), copy: copy.trim(), tone } - write('categories', [...read('categories', []), category]) + async create({ label, copy, tone, archived = false, order = null }) { + const categories = read('categories', []) + const nextOrder = order ?? (Math.max(0, ...categories.map((item) => item.order || 0)) + 1) + const category = { id: uid('cat'), label: label.trim(), copy: copy.trim(), tone, archived, order: nextOrder } + write('categories', [...categories, category]) return category }, async update(id, patch) { diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 7633447..888ac7f 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -8,6 +8,10 @@ .admin-split { @apply grid grid-cols-1 gap-6 lg:grid-cols-[1.25fr_.75fr]; } .admin-split-categories { @apply lg:grid-cols-[.8fr_1.2fr]; } .admin-body .panel + .panel, .admin-split .panel + .panel { @apply mt-0; } + .admin-toolbar { @apply flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between; } + .admin-alert { @apply mb-4; } + .inline-link { @apply ml-2 inline-flex items-center rounded-full bg-transparent px-2 py-1 text-sm font-bold text-accent-ink underline underline-offset-4; } + .skeleton-chart { @apply h-48 w-full animate-pulse rounded-2xl bg-panel; } .stat-grid { @apply grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4; } .stat-card { @apply relative flex flex-col overflow-hidden rounded-3xl border border-line bg-surface p-6 shadow-soft; } @@ -39,6 +43,8 @@ .table tbody tr:hover { @apply bg-panel; } .inline-input { @apply w-full min-w-40 py-2.5 text-sm sm:w-64; } + .bulk-actions { @apply mt-4 flex flex-col gap-3 rounded-2xl border border-line bg-panel p-4 sm:flex-row sm:items-end; } + .bulk-actions .field { @apply mb-0 flex-1; } .upload { @apply grid grid-cols-1 gap-8 lg:grid-cols-[1.1fr_.9fr]; } .upload-drop { @apply min-w-0; } @@ -52,6 +58,8 @@ .video-list { @apply m-0 list-none p-0; } .video-row { @apply border-t border-line py-5; } .video-row-main { @apply flex flex-wrap items-center gap-x-4 gap-y-3; } + .row-select { @apply inline-flex items-center; } + .row-select input { @apply h-4 w-4 rounded border-line-strong accent-brand; } .video-row-thumb { @apply h-14 w-24 shrink-0 rounded-xl bg-panel; } .video-row-info { @apply min-w-0 flex-1 basis-44; } .video-row-info b { @apply block font-display text-2xl font-bold leading-none text-heading; } @@ -62,14 +70,20 @@ .video-row-preview { @apply mt-4 block aspect-video w-full max-w-xl rounded-2xl bg-black; } .video-row-edit { @apply mt-2 grid grid-cols-1 gap-x-5 sm:grid-cols-2; } .row-form-actions { @apply mt-6 flex flex-wrap gap-2 sm:col-span-2; } + .admin-inline-move { @apply mt-2 sm:col-span-2; } .category-list { @apply mt-8 grid list-none gap-1.5 border-t border-line p-0 pt-5; } + .category-list-archive { @apply mt-4; } .category-list-item { @apply flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left font-ui text-base font-bold text-heading transition hover:bg-panel; } .category-list-item.is-active { @apply bg-secondary-soft; } .category-list-item small { @apply ml-auto text-sm text-muted; } .swatch { @apply h-4 w-4 shrink-0 rounded-full; } + .category-stats { @apply mb-6 mt-4 grid gap-3 rounded-2xl border border-line bg-panel px-4 py-3 text-sm text-muted sm:grid-cols-3; } + .category-stats b { @apply block font-ui text-lg font-bold text-heading; } .category-edit { @apply mt-2 grid grid-cols-1 gap-x-5 sm:grid-cols-2; } .field-wide { @apply sm:col-span-2; } + .category-order-actions { @apply mt-4 flex flex-wrap gap-2; } + .category-archive-block { @apply mt-8; } .assign-title { @apply mb-2 mt-10 flex items-center gap-3; } .assign-list { @apply m-0 list-none p-0; } .assign-list li { @apply flex items-center justify-between gap-4 border-t border-line py-3.5; } From 675ed1bae9dbe3ae0a19c79bd7fd56598eabd44e Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 16:21:59 +0500 Subject: [PATCH 2/5] remove level filter --- backend/README.md | 2 +- backend/src/config/constants.js | 1 - .../middlewares/validators/videoSchemas.js | 5 +---- backend/src/models/Video.js | 3 +-- backend/src/services/videoService.js | 4 ++-- backend/tests/api.test.js | 4 ++-- frontend/src/mock/data.js | 19 +++++++++---------- frontend/src/pages/CategoryBrowser.jsx | 8 ++------ frontend/src/pages/Home.jsx | 2 +- frontend/src/pages/Player.jsx | 2 +- frontend/src/pages/admin/AdminCategories.jsx | 2 +- frontend/src/pages/admin/AdminVideos.jsx | 10 ++++------ frontend/src/services/api.js | 4 ++-- 13 files changed, 27 insertions(+), 39 deletions(-) diff --git a/backend/README.md b/backend/README.md index ffcb3fe..3e4b4cc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -61,7 +61,7 @@ Codes: `BAD_REQUEST` 400, `UNAUTHORIZED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404, | `GET /videos`, `GET /videos/:id` | anyone | Visitors and learners: published + categorised only. Admin: everything | | `GET /videos/:id/poster` | anyone (same visibility) | | | `GET /videos/:id/file` | logged in | Streams with Range support | -| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `level`, `status`, `durationSec` | +| `POST /videos` | admin | `multipart/form-data`: `video` (required), `poster` (optional), `title`, `categoryId`, `status`, `durationSec` | | `PATCH`/`DELETE /videos/:id` | admin | Moving to another category appends to the end of that path | | `GET /submissions` | logged in | Learners: their own. Admin: all | | `POST /submissions` | logged in | `multipart/form-data`: `recording`, `videoId`, `trimStart`, `trimEnd`, `mirrored`, `duration` | diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js index 126eb5b..676eb8b 100644 --- a/backend/src/config/constants.js +++ b/backend/src/config/constants.js @@ -4,7 +4,6 @@ export const AUTH_COOKIE = 'signpak_token' export const ROLES = { USER: 'user', ADMIN: 'admin' } export const ROLE_LIST = Object.values(ROLES) -export const LEVELS = ['Beginner', 'Practice', 'Intermediate', 'Advanced'] export const TONES = ['yellow', 'blue', 'red', 'green'] export const VIDEO_STATUS = { PUBLISHED: 'published', DRAFT: 'draft' } export const VIDEO_STATUS_LIST = Object.values(VIDEO_STATUS) diff --git a/backend/src/middlewares/validators/videoSchemas.js b/backend/src/middlewares/validators/videoSchemas.js index 4020d02..f17a5df 100644 --- a/backend/src/middlewares/validators/videoSchemas.js +++ b/backend/src/middlewares/validators/videoSchemas.js @@ -1,16 +1,14 @@ import { z } from 'zod' -import { LEVELS, VIDEO_STATUS, VIDEO_STATUS_LIST } from '../../config/constants.js' +import { VIDEO_STATUS, VIDEO_STATUS_LIST } from '../../config/constants.js' import { objectId } from './common.js' const title = z.string({ error: 'Give the video a title.' }).trim().min(1, 'Give the video a title.').max(120, 'Keep the title under 120 characters.') -const level = z.enum(LEVELS, { error: `Level must be one of: ${LEVELS.join(', ')}.` }) const status = z.enum(VIDEO_STATUS_LIST, { error: `Status must be one of: ${VIDEO_STATUS_LIST.join(', ')}.` }) // An empty string (from a form's "Unassigned" option) means no category. const categoryId = z.preprocess((value) => (value === '' ? null : value), objectId('category').nullable()) export const createVideoSchema = z.object({ title, - level: level.default(LEVELS[0]), status: status.default(VIDEO_STATUS.PUBLISHED), categoryId: categoryId.optional().default(null), durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), @@ -18,7 +16,6 @@ export const createVideoSchema = z.object({ export const updateVideoSchema = z.object({ title: title.optional(), - level: level.optional(), status: status.optional(), categoryId: categoryId.optional(), order: z.coerce.number().int().min(0).optional(), diff --git a/backend/src/models/Video.js b/backend/src/models/Video.js index 6723ab7..114d342 100644 --- a/backend/src/models/Video.js +++ b/backend/src/models/Video.js @@ -1,11 +1,10 @@ import mongoose from 'mongoose' -import { API_PREFIX, LEVELS, VIDEO_STATUS, VIDEO_STATUS_LIST } from '../config/constants.js' +import { API_PREFIX, VIDEO_STATUS, VIDEO_STATUS_LIST } from '../config/constants.js' import { jsonOptions } from '../utils/mongoose.js' import { storedFileSchema } from './storedFile.js' const videoSchema = new mongoose.Schema({ title: { type: String, required: true, trim: true, maxlength: 120 }, - level: { type: String, enum: LEVELS, default: LEVELS[0] }, status: { type: String, enum: VIDEO_STATUS_LIST, default: VIDEO_STATUS.PUBLISHED }, category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', default: null }, order: { type: Number, default: 1, min: 0 }, diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js index e3af417..524645e 100644 --- a/backend/src/services/videoService.js +++ b/backend/src/services/videoService.js @@ -60,7 +60,7 @@ export const videoService = { } const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 return await videoRepo.create({ - title: input.title, level: input.level, status: input.status, durationSec: input.durationSec, + title: input.title, status: input.status, durationSec: input.durationSec, category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id, }) } catch (error) { @@ -80,7 +80,7 @@ export const videoService = { // Moving into a category puts the video at the end of that path unless an order was given. if (moved && patch.categoryId && patch.order === undefined) video.order = (await videoRepo.maxOrder(patch.categoryId)) + 1 } - for (const key of ['title', 'level', 'status', 'order']) { + for (const key of ['title', 'status', 'order']) { if (patch[key] !== undefined) video[key] = patch[key] } return videoRepo.save(video) diff --git a/backend/tests/api.test.js b/backend/tests/api.test.js index 9b4b181..eed8da7 100644 --- a/backend/tests/api.test.js +++ b/backend/tests/api.test.js @@ -181,7 +181,7 @@ describe('videos', () => { const fake = await admin.upload('/videos', videoForm({ title: 'Fake' }, { video: Buffer.from('this is just text, not a video at all') })) assert.equal(fake.status, 422) assert.match(fake.body.error.fields.video, /MP4, WebM or MOV/) - const noTitle = await admin.upload('/videos', videoForm({ level: 'Beginner' })) + const noTitle = await admin.upload('/videos', videoForm({})) assert.equal(noTitle.status, 422) assert.ok(noTitle.body.error.fields.title) const missingCategory = await admin.upload('/videos', videoForm({ title: 'Ghost cat', categoryId: '64b7f0f0f0f0f0f0f0f0f0f0' })) @@ -193,7 +193,7 @@ describe('videos', () => { }) test('admin uploads with and without a poster; the API hides storage internals', async () => { - const a = await admin.upload('/videos', videoForm({ title: 'Nice to meet you', categoryId: daily.id, level: 'Beginner', durationSec: '134' }, { poster: jpeg() })) + const a = await admin.upload('/videos', videoForm({ title: 'Nice to meet you', categoryId: daily.id, durationSec: '134' }, { poster: jpeg() })) assert.equal(a.status, 201, JSON.stringify(a.body)) lesson1 = a.body.data assert.equal(lesson1.categoryId, daily.id) diff --git a/frontend/src/mock/data.js b/frontend/src/mock/data.js index 0528024..befaad3 100644 --- a/frontend/src/mock/data.js +++ b/frontend/src/mock/data.js @@ -6,7 +6,6 @@ export const SAMPLE_VIDEO = 'https://interactive-examples.mdn.mozilla.net/media/ // Replace with your own file, e.g. '/demo/how-it-works.mp4' placed in frontend/public/demo/ export const DEMO_VIDEO = SAMPLE_VIDEO -export const LEVELS = ['Beginner', 'Practice', 'Intermediate', 'Advanced'] export const TONES = ['yellow', 'blue', 'red', 'green'] // The keys are stored in the database, so they stay. These are the colours people actually see. export const TONE_LABELS = { yellow: 'Lime', blue: 'Sky', red: 'Turquoise', green: 'Mint' } @@ -35,19 +34,19 @@ export const seedCategories = [ ] const poster = (id) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=85` -const video = (id, order, title, level, durationSec, categoryId, image) => ({ - id: `video_${id}`, title, level, durationSec, categoryId, order, +const video = (id, order, title, durationSec, categoryId, image) => ({ + id: `video_${id}`, title, durationSec, categoryId, order, poster: poster(image), videoUrl: SAMPLE_VIDEO, status: 'published', createdAt: now - (30 - id) * day, }) export const seedVideos = [ - video(1, 1, 'Nice to meet you', 'Beginner', 134, 'daily', 'photo-1551836022-d5d88e9218df'), - video(2, 2, 'A warm introduction', 'Beginner', 188, 'daily', 'photo-1521737711867-e3b97375f902'), - video(3, 3, 'Where are you from?', 'Practice', 102, 'daily', 'photo-1529156069898-49953e39b3ac'), - video(4, 1, 'The team check-in', 'Practice', 260, 'work', 'photo-1556761175-b413da4baf72'), - video(5, 2, 'Could you show me?', 'Intermediate', 171, 'work', 'photo-1543269865-cbf427effbad'), - video(6, 1, 'Finding your way', 'Beginner', 212, 'travel', 'photo-1530789253388-582c481c54b0'), - video(7, 1, 'A story in motion', 'Advanced', 310, 'stories', 'photo-1531058020387-3be344556be6'), + video(1, 1, 'Nice to meet you', 134, 'daily', 'photo-1551836022-d5d88e9218df'), + video(2, 2, 'A warm introduction', 188, 'daily', 'photo-1521737711867-e3b97375f902'), + video(3, 3, 'Where are you from?', 102, 'daily', 'photo-1529156069898-49953e39b3ac'), + video(4, 1, 'The team check-in', 260, 'work', 'photo-1556761175-b413da4baf72'), + video(5, 2, 'Could you show me?', 171, 'work', 'photo-1543269865-cbf427effbad'), + video(6, 1, 'Finding your way', 212, 'travel', 'photo-1530789253388-582c481c54b0'), + video(7, 1, 'A story in motion', 310, 'stories', 'photo-1531058020387-3be344556be6'), ] // A few past submissions from the demo contributors so the admin dashboard has something to chart. diff --git a/frontend/src/pages/CategoryBrowser.jsx b/frontend/src/pages/CategoryBrowser.jsx index dec3154..5e3f4d8 100644 --- a/frontend/src/pages/CategoryBrowser.jsx +++ b/frontend/src/pages/CategoryBrowser.jsx @@ -17,14 +17,11 @@ export default function CategoryBrowser() { const categoryId = safeRouteId(rawCategoryId) const { categories, videosIn, hasSubmission, submissionCount } = useLibrary() const [query, setQuery] = useState('') - const [level, setLevel] = useState('All levels') const [sort, setSort] = useState('path') const category = categories.find((item) => item.id === categoryId) const all = useMemo(() => (category ? videosIn(category.id) : []), [category, videosIn]) - const levels = ['All levels', ...new Set(all.map((item) => item.level))] const videos = all - .filter((item) => level === 'All levels' || item.level === level) .filter((item) => item.title.toLowerCase().includes(query.trim().toLowerCase())) .sort(SORTS[sort].compare) @@ -43,7 +40,6 @@ export default function CategoryBrowser() { -
    } /> @@ -58,13 +54,13 @@ export default function CategoryBrowser() { {hasSubmission(video.id) && ✓ {count} submitted} -

    {video.level} · {formatTime(video.durationSec)}

    +

    {formatTime(video.durationSec)}

    {video.title}

    {hasSubmission(video.id) ? 'Record another' : 'Open video'} ↗ })} - :

    {all.length ? 'No videos match those filters. ' : 'No videos in this category yet. '}{all.length > 0 && }

    } + :

    {all.length ? 'No videos match that search. ' : 'No videos in this category yet. '}{all.length > 0 && }

    } } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index d2718c7..32b5b33 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -27,7 +27,7 @@ export default function Home() {
    Keep contributing ✦

    {nextUp.title}

    -

    {nextCategory?.label} · {nextUp.level}

    +

    {nextCategory?.label}

    {contributedCount} of {orderedPublished.length} videos have a recording from you
    ▶ diff --git a/frontend/src/pages/Player.jsx b/frontend/src/pages/Player.jsx index ee341ab..40a2d1d 100644 --- a/frontend/src/pages/Player.jsx +++ b/frontend/src/pages/Player.jsx @@ -61,7 +61,7 @@ function PlayerScreen({ videoId }) { const seconds = Math.ceil(remaining / 1000) return
    -
    ← Back to videos{video.level} · Video {String(number).padStart(2, '0')}
    +
    ← Back to videosVideo {String(number).padStart(2, '0')}
    diff --git a/frontend/src/pages/admin/AdminCategories.jsx b/frontend/src/pages/admin/AdminCategories.jsx index 4d6649e..e098b76 100644 --- a/frontend/src/pages/admin/AdminCategories.jsx +++ b/frontend/src/pages/admin/AdminCategories.jsx @@ -126,7 +126,7 @@ function CategoryDetail({ category, onDeleted }) {

    Videos in this category {assigned.length}

    {assigned.length - ?
      {assigned.map((video) =>
    • {video.title}{video.level} · {formatTime(video.durationSec)} · {video.status}
    • )}
    + ?
      {assigned.map((video) =>
    • {video.title}{formatTime(video.durationSec)} · {video.status}
    • )}
    :

    No videos yet. Add some from the list below.

    }

    Add a video

    diff --git a/frontend/src/pages/admin/AdminVideos.jsx b/frontend/src/pages/admin/AdminVideos.jsx index d3234f7..7f5177a 100644 --- a/frontend/src/pages/admin/AdminVideos.jsx +++ b/frontend/src/pages/admin/AdminVideos.jsx @@ -2,7 +2,6 @@ import { useState } from 'react' import Field from '../../components/Field' import { Alert, Badge, Button, SectionHeading, VideoArtwork } from '../../components/ui' import { useLibrary } from '../../context/LibraryContext' -import { LEVELS } from '../../mock/data' import { formatBytes, formatTime } from '../../utils/format' import { inspectVideoFile } from '../../utils/media' @@ -76,7 +75,7 @@ function UploadPanel() { const [file, setFile] = useState(null) const [previewUrl, setPreviewUrl] = useState('') const [meta, setMeta] = useState({ durationSec: 0, poster: '' }) - const [form, setForm] = useState({ title: '', categoryId: '', level: LEVELS[0], status: 'published' }) + const [form, setForm] = useState({ title: '', categoryId: '', status: 'published' }) const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) const change = (event) => setForm((current) => ({ ...current, [event.target.name]: event.target.value })) @@ -106,7 +105,7 @@ function UploadPanel() { await addVideo({ file, ...form, ...meta }) setMessage({ tone: 'success', text: `“${form.title.trim()}” uploaded${form.status === 'published' && form.categoryId ? ' and live for contributors.' : '. Assign a category and publish it to make it visible.'}` }) URL.revokeObjectURL(previewUrl) - setFile(null); setPreviewUrl(''); setForm({ title: '', categoryId: '', level: LEVELS[0], status: 'published' }) + setFile(null); setPreviewUrl(''); setForm({ title: '', categoryId: '', status: 'published' }) } catch (error) { setMessage({ tone: 'error', text: error?.message || 'The upload failed. Check that your browser has storage space and try again.' }) } finally { setBusy(false) } @@ -136,7 +135,7 @@ function UploadPanel() { function VideoRow({ video, videos, categories, selected, onToggleSelect }) { const { editVideo, deleteVideo } = useLibrary() const [mode, setMode] = useState('view') // view | edit | preview | delete - const [draft, setDraft] = useState({ title: video.title, level: video.level, categoryId: video.categoryId || '', status: video.status }) + const [draft, setDraft] = useState({ title: video.title, categoryId: video.categoryId || '', status: video.status }) const category = categories.find((item) => item.id === video.categoryId) const change = (event) => setDraft((current) => ({ ...current, [event.target.name]: event.target.value })) @@ -161,7 +160,7 @@ function VideoRow({ video, videos, categories, selected, onToggleSelect }) {
    -
    {video.title}{category?.label || 'Unassigned'} · {video.level} · {formatTime(video.durationSec)}
    +
    {video.title}{category?.label || 'Unassigned'} · {formatTime(video.durationSec)}
    {video.status === 'published' ? 'Published' : 'Draft'}
    @@ -174,7 +173,6 @@ function VideoRow({ video, videos, categories, selected, onToggleSelect }) { {mode === 'edit' &&
    {categories.map((item) => )} - {LEVELS.map((item) => )}
    diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index c4fdc5c..eff0074 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -130,13 +130,13 @@ export const api = { videos: { async list() { return Promise.all(read('videos', []).map(withPlayableUrl)) }, /** `file` is the uploaded base video. With a real backend this becomes a multipart upload. */ - async create({ file, title, categoryId, level, status, durationSec, poster }) { + async create({ file, title, categoryId, status, durationSec, poster }) { const videos = read('videos', []) const id = uid('video') const blobKey = `blob_${id}` await blobStore.put(blobKey, file) const order = Math.max(0, ...videos.filter((item) => item.categoryId === categoryId).map((item) => item.order || 0)) + 1 - const video = { id, title: title.trim(), level, status, categoryId: categoryId || null, order, durationSec, poster, blobKey, size: file.size, createdAt: Date.now() } + const video = { id, title: title.trim(), status, categoryId: categoryId || null, order, durationSec, poster, blobKey, size: file.size, createdAt: Date.now() } write('videos', [...videos, video]) return video }, From 8e6878ce5200b888739f157ea84e93405c0a8b16 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 16:25:49 +0500 Subject: [PATCH 3/5] fix --- frontend/src/styles/admin.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 888ac7f..a05f4fd 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -31,7 +31,7 @@ .bar-list li { @apply grid grid-cols-[minmax(0,7.5rem)_1fr_auto] items-center gap-3 text-sm font-semibold; } .bar-list b { @apply font-ui text-base; } .bar-track { @apply h-3.5 overflow-hidden rounded-full bg-panel; } - .bar-fill { @apply block h-full min-w-[4px] rounded-full; } + .bar-fill { @apply block h-full min-w-1 rounded-full; } /* overflow-x:auto on a table does not stop Chromium counting the table's full width in document.documentElement.scrollWidth unless the wrapper also gets its @@ -48,7 +48,7 @@ .upload { @apply grid grid-cols-1 gap-8 lg:grid-cols-[1.1fr_.9fr]; } .upload-drop { @apply min-w-0; } - .upload-pick { @apply grid min-h-[280px] cursor-pointer place-content-center justify-items-center gap-2 rounded-3xl border-2 border-dashed border-line-strong bg-panel p-6 text-center text-base text-muted transition hover:border-accent-ink hover:bg-secondary-soft; } + .upload-pick { @apply grid min-h-70 cursor-pointer place-content-center justify-items-center gap-2 rounded-3xl border-2 border-dashed border-line-strong bg-panel p-6 text-center text-base text-muted transition hover:border-accent-ink hover:bg-secondary-soft; } .upload-preview { @apply block aspect-video w-full rounded-2xl bg-black; } .upload-file { @apply mt-3 text-sm text-muted; } .upload-file b { @apply text-heading; } From 58dbaadc15df5bfa9f14bc7537fde771f1c39127 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 16:38:48 +0500 Subject: [PATCH 4/5] admin panel --- frontend/src/pages/admin/AdminCategories.jsx | 22 +--- frontend/src/pages/admin/AdminDashboard.jsx | 113 +++++++++++++++---- frontend/src/styles/admin.css | 8 +- 3 files changed, 100 insertions(+), 43 deletions(-) diff --git a/frontend/src/pages/admin/AdminCategories.jsx b/frontend/src/pages/admin/AdminCategories.jsx index e098b76..b59dc33 100644 --- a/frontend/src/pages/admin/AdminCategories.jsx +++ b/frontend/src/pages/admin/AdminCategories.jsx @@ -8,7 +8,7 @@ import { formatTime } from '../../utils/format' const NEW_CATEGORY = { label: '', copy: '', tone: TONES[0] } export default function AdminCategories() { - const { categories, videos, submissions, addCategory } = useLibrary() + const { categories, videos, addCategory } = useLibrary() const [selectedId, setSelectedId] = useState(categories[0]?.id || null) const [form, setForm] = useState({ ...NEW_CATEGORY, archived: false }) const [error, setError] = useState('') @@ -40,7 +40,6 @@ export default function AdminCategories() { {TONES.map((tone) => )} - @@ -83,20 +82,6 @@ function CategoryDetail({ category, onDeleted }) { } const unassign = (video) => editVideo(video.id, { categoryId: null }) - const moveCategoryOrder = async (direction) => { - const ordered = [...categories].sort((a, b) => (a.order || 0) - (b.order || 0)) - const index = ordered.findIndex((item) => item.id === category.id) - const targetIndex = index + direction - if (index < 0 || targetIndex < 0 || targetIndex >= ordered.length) return - const target = ordered[targetIndex] - const currentOrder = category.order || 0 - const nextOrder = target.order || 0 - await Promise.all([ - editCategory(category.id, { order: nextOrder }), - editCategory(target.id, { order: currentOrder }), - ]) - } - return
    Edit category

    {category.label}

    @@ -109,11 +94,6 @@ function CategoryDetail({ category, onDeleted }) { {TONES.map((tone) => )} - -
    -
    - -
    diff --git a/frontend/src/pages/admin/AdminDashboard.jsx b/frontend/src/pages/admin/AdminDashboard.jsx index 6b3801c..d84b1e0 100644 --- a/frontend/src/pages/admin/AdminDashboard.jsx +++ b/frontend/src/pages/admin/AdminDashboard.jsx @@ -1,21 +1,22 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import Field from '../../components/Field' import { Badge, SectionHeading } from '../../components/ui' import { useLibrary } from '../../context/LibraryContext' import { api } from '../../services/api' import { formatDate } from '../../utils/format' -const DAYS = 14 +const DAY_OPTIONS = [7, 14, 30, 60, 90] const DAY_MS = 24 * 60 * 60 * 1000 function ActivityChart({ counts, labels }) { const width = 600 const height = 180 const max = Math.max(1, ...counts) - const step = width / (counts.length - 1) + const step = counts.length > 1 ? width / (counts.length - 1) : width const points = counts.map((count, index) => [index * step, height - 10 - (count / max) * (height - 30)]) const line = points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ') return
    - + {points.map(([x, y], index) => {`${labels[index]}: ${counts[index]}`})} @@ -29,6 +30,9 @@ export default function AdminDashboard() { const [users, setUsers] = useState([]) const [usersError, setUsersError] = useState('') const [usersLoading, setUsersLoading] = useState(true) + const [days, setDays] = useState(14) + const [selectedContributor, setSelectedContributor] = useState('all') + const [selectedCategory, setSelectedCategory] = useState('all') useEffect(() => { let active = true @@ -40,44 +44,113 @@ export default function AdminDashboard() { }, []) const contributors = users.filter((user) => user.role === 'user') - const published = videos.filter((video) => video.status === 'published').length const startOfToday = new Date().setHours(0, 0, 0, 0) - const counts = Array.from({ length: DAYS }, (_, index) => { - const from = startOfToday - (DAYS - 1 - index) * DAY_MS - return submissions.filter((item) => item.submittedAt >= from && item.submittedAt < from + DAY_MS).length + const rangeStart = startOfToday - (days - 1) * DAY_MS + + const filteredSubmissions = useMemo(() => submissions.filter((item) => { + if (item.submittedAt < rangeStart) return false + if (selectedContributor !== 'all' && item.userId !== selectedContributor) return false + if (selectedCategory !== 'all') { + const video = videos.find((entry) => entry.id === item.videoId) + return video?.categoryId === selectedCategory + } + return true + }), [rangeStart, selectedCategory, selectedContributor, submissions, videos]) + + const counts = Array.from({ length: days }, (_, index) => { + const from = startOfToday - (days - 1 - index) * DAY_MS + return filteredSubmissions.filter((item) => item.submittedAt >= from && item.submittedAt < from + DAY_MS).length }) - const labels = counts.map((_, index) => new Date(startOfToday - (DAYS - 1 - index) * DAY_MS).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })) + + const labels = counts.map((_, index) => new Date(startOfToday - (days - 1 - index) * DAY_MS).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })) const perCategory = categories.map((category) => { - const ids = new Set(videos.filter((video) => video.categoryId === category.id).map((video) => video.id)) - return { category, count: submissions.filter((item) => ids.has(item.videoId)).length } + const ids = new Set(videos.filter((video) => video.status === 'published' && video.categoryId === category.id).map((video) => video.id)) + const count = filteredSubmissions.filter((item) => ids.has(item.videoId)).length + return { category, count, totalVideos: ids.size } }) + const topCount = Math.max(1, ...perCategory.map((item) => item.count)) - const recent = [...submissions].sort((a, b) => b.submittedAt - a.submittedAt).slice(0, 6) + const recent = [...filteredSubmissions].sort((a, b) => b.submittedAt - a.submittedAt).slice(0, 6) + + const topVideos = videos + .filter((video) => video.status === 'published' && (selectedCategory === 'all' || video.categoryId === selectedCategory)) + .map((video) => ({ + video, + count: filteredSubmissions.filter((item) => item.videoId === video.id).length, + category: categories.find((entry) => entry.id === video.categoryId), + })) + .filter((item) => item.count > 0) + .sort((a, b) => b.count - a.count || a.video.title.localeCompare(b.video.title)) + .slice(0, 6) + + const contributorStats = contributors + .map((user) => ({ + user, + count: filteredSubmissions.filter((item) => item.userId === user.id).length, + })) + .filter((item) => item.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 5) + const stats = [ - ['Contributors', contributors.length, 'signed up'], - ['Base videos', videos.length, `${published} published`], - ['Recordings submitted', submissions.length, 'across all videos'], - ['Categories', categories.length, 'topic groups'], + ['Active contributors', new Set(filteredSubmissions.map((item) => item.userId)).size], + ['Videos recorded', new Set(filteredSubmissions.map((item) => item.videoId)).size], + ['Recordings submitted', filteredSubmissions.length], + ['Top category', perCategory.reduce((winner, item) => (item.count > winner.count ? item : winner), { category: { label: '—' }, count: 0 }).category.label], ] return <> {usersError &&

    {usersError}

    } +
    + setSelectedContributor(event.target.value)}> + + {contributors.map((user) => )} + + setDays(Number(event.target.value))}> + {DAY_OPTIONS.map((value) => )} + + setSelectedCategory(event.target.value)}> + + {categories.map((category) => )} + +
    +
    {stats.map(([label, value, note]) =>
    {label}{value}{note}
    )}
    - Last {DAYS} days} /> + Last {days} days} /> {usersLoading ?
    : }
    - +
      - {perCategory.length ? perCategory.map(({ category, count }) =>
    • {category.label}
      {count}
    • ) :
    • No categories yet.
    • } + {perCategory.length ? perCategory.map(({ category, count, totalVideos }) =>
    • {category.label}
      {count}
    • ) :
    • No categories yet.
    • }
    +

    {selectedCategory === 'all' ? 'This shows the category mix across the selected range.' : `Category focus: ${categories.find((entry) => entry.id === selectedCategory)?.label || 'Selected category'}.`}

    +
    +
    + +
    +
    + + {topVideos.length + ?
      + {topVideos.map(({ video, count, category }) =>
    • {video.title}
      {count}
    • )} +
    + :

    No recorded videos in this range yet.

    } +
    +
    + + {contributorStats.length + ?
      + {contributorStats.map(({ user, count }) =>
    • {user.firstName} {user.surname}
      {count}
    • )} +
    + :

    No contributor activity in this view.

    }
    @@ -96,7 +169,7 @@ export default function AdminDashboard() { })}
    - :

    No submissions yet. As soon as contributors upload recordings, they will appear here.

    } + :

    No submissions match this filter. Try a broader date range or a different contributor.

    }
    } diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index a05f4fd..74305ef 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -10,8 +10,13 @@ .admin-body .panel + .panel, .admin-split .panel + .panel { @apply mt-0; } .admin-toolbar { @apply flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between; } .admin-alert { @apply mb-4; } + .admin-filters { @apply mb-6 grid grid-cols-1 gap-4 md:grid-cols-3; } + .admin-filters .field { @apply mb-0; } + .admin-filters .field-control { @apply min-h-12; } .inline-link { @apply ml-2 inline-flex items-center rounded-full bg-transparent px-2 py-1 text-sm font-bold text-accent-ink underline underline-offset-4; } .skeleton-chart { @apply h-48 w-full animate-pulse rounded-2xl bg-panel; } + .panel-copy { @apply mt-4 text-sm text-muted; } + .micro-copy { @apply text-xs; } .stat-grid { @apply grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4; } .stat-card { @apply relative flex flex-col overflow-hidden rounded-3xl border border-line bg-surface p-6 shadow-soft; } @@ -29,6 +34,7 @@ .bar-list { @apply m-0 grid list-none gap-5 p-0; } .bar-list li { @apply grid grid-cols-[minmax(0,7.5rem)_1fr_auto] items-center gap-3 text-sm font-semibold; } + .smooth-list li { @apply text-xs; } .bar-list b { @apply font-ui text-base; } .bar-track { @apply h-3.5 overflow-hidden rounded-full bg-panel; } .bar-fill { @apply block h-full min-w-1 rounded-full; } @@ -82,8 +88,6 @@ .category-stats b { @apply block font-ui text-lg font-bold text-heading; } .category-edit { @apply mt-2 grid grid-cols-1 gap-x-5 sm:grid-cols-2; } .field-wide { @apply sm:col-span-2; } - .category-order-actions { @apply mt-4 flex flex-wrap gap-2; } - .category-archive-block { @apply mt-8; } .assign-title { @apply mb-2 mt-10 flex items-center gap-3; } .assign-list { @apply m-0 list-none p-0; } .assign-list li { @apply flex items-center justify-between gap-4 border-t border-line py-3.5; } From f7feb1a9ba5b30f53b7a2e8951336f4466be859c Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Fri, 25 Sep 2026 16:43:15 +0500 Subject: [PATCH 5/5] rm: add video from categories --- frontend/src/pages/admin/AdminCategories.jsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/frontend/src/pages/admin/AdminCategories.jsx b/frontend/src/pages/admin/AdminCategories.jsx index b59dc33..9f37d61 100644 --- a/frontend/src/pages/admin/AdminCategories.jsx +++ b/frontend/src/pages/admin/AdminCategories.jsx @@ -65,21 +65,16 @@ export default function AdminCategories() { } function CategoryDetail({ category, onDeleted }) { - const { categories, videos, submissions, editCategory, deleteCategory, editVideo } = useLibrary() + const { videos, submissions, editCategory, deleteCategory, editVideo } = useLibrary() const [draft, setDraft] = useState({ label: category.label, copy: category.copy, tone: category.tone, archived: !!category.archived }) const [saved, setSaved] = useState(false) const [confirming, setConfirming] = useState(false) const assigned = videos.filter((video) => video.categoryId === category.id).sort((a, b) => (a.order || 0) - (b.order || 0)) - const others = videos.filter((video) => video.categoryId !== category.id) const change = (event) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; setSaved(false); setDraft((current) => ({ ...current, [event.target.name]: value })) } const categorySubmissionCount = submissions.filter((item) => assigned.some((video) => video.id === item.videoId)).length const publishedCount = assigned.filter((video) => video.status === 'published').length - const assign = async (video) => { - const nextOrder = Math.max(0, ...assigned.map((item) => item.order || 0)) + 1 - await editVideo(video.id, { categoryId: category.id, order: nextOrder }) - } const unassign = (video) => editVideo(video.id, { categoryId: null }) return
    @@ -108,10 +103,5 @@ function CategoryDetail({ category, onDeleted }) { {assigned.length ?
      {assigned.map((video) =>
    • {video.title}{formatTime(video.durationSec)} · {video.status}
    • )}
    :

    No videos yet. Add some from the list below.

    } - -

    Add a video

    - {others.length - ?
      {others.map((video) =>
    • {video.title}Now in: {categories.find((item) => item.id === video.categoryId)?.label || 'Unassigned'}
    • )}
    - :

    Every video is already in this category.

    }
    }