Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 0 additions & 1 deletion backend/src/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 1 addition & 4 deletions backend/src/middlewares/validators/videoSchemas.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
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),
})

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(),
Expand Down
3 changes: 1 addition & 2 deletions backend/src/models/Video.js
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/videoService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand All @@ -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)
Expand Down
54 changes: 0 additions & 54 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 9 additions & 10 deletions frontend/src/mock/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/pages/CategoryBrowser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -43,7 +40,6 @@ export default function CategoryBrowser() {
<SectionHeading eyebrow={`${videos.length} showing`} title="Pick a video." action={
<div className="filters">
<select value={sort} onChange={(event) => setSort(event.target.value)} aria-label="Sort videos">{Object.entries(SORTS).map(([key, item]) => <option key={key} value={key}>{item.label}</option>)}</select>
<select value={level} onChange={(event) => setLevel(event.target.value)} aria-label="Filter by level">{levels.map((item) => <option key={item}>{item}</option>)}</select>
</div>
} />
<label className="search"><span>Search videos</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Try “introduction”" /></label>
Expand All @@ -58,13 +54,13 @@ export default function CategoryBrowser() {
{hasSubmission(video.id) && <span className="video-card-done"><Badge tone="success">✓ {count} submitted</Badge></span>}
<i className="video-card-play" aria-hidden="true">▶</i>
</VideoArtwork>
<p className="video-card-meta">{video.level} · {formatTime(video.durationSec)}</p>
<p className="video-card-meta">{formatTime(video.durationSec)}</p>
<h3 className="display display-sm">{video.title}</h3>
<span className="video-card-cta">{hasSubmission(video.id) ? 'Record another' : 'Open video'} ↗</span>
</Link>
})}
</div>
: <p className="empty-note">{all.length ? 'No videos match those filters. ' : 'No videos in this category yet. '}{all.length > 0 && <button type="button" className="link-accent" onClick={() => { setQuery(''); setLevel('All levels') }}>Clear filters</button>}</p>}
: <p className="empty-note">{all.length ? 'No videos match that search. ' : 'No videos in this category yet. '}{all.length > 0 && <button type="button" className="link-accent" onClick={() => setQuery('')}>Clear search</button>}</p>}
</section>
</main>
}
7 changes: 3 additions & 4 deletions frontend/src/pages/Home.jsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -20,15 +20,14 @@ export default function Home() {
<h1 className="display display-xl">{greeting()}, <em>{user.firstName}.</em></h1>
<p className="lede">Which video would you like to contribute to today?</p>
</div>
<Link to={paths.profile} className="chip-link"><Avatar user={user} size="sm" />{user.firstName} {user.surname}</Link>
</section>

<section className="shell continue">
{nextUp ? <>
<div>
<Eyebrow>Keep contributing ✦</Eyebrow>
<h2 className="display display-md">{nextUp.title}</h2>
<p className="continue-meta">{nextCategory?.label} · {nextUp.level}</p>
<p className="continue-meta">{nextCategory?.label}</p>
<div className="continue-progress"><ProgressBar value={percent} label="Coverage across the library" /><span>{contributedCount} of {orderedPublished.length} videos have a recording from you</span></div>
</div>
<Link to={watchUrl(nextUp.id)} className="continue-play" aria-label={`Open video: ${nextUp.title}`}>▶</Link>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Player.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function PlayerScreen({ videoId }) {
const seconds = Math.ceil(remaining / 1000)

return <main className="page">
<div className="shell player-top"><Link to={categoryUrl(video.categoryId)}>← Back to videos</Link><span>{video.level} · Video {String(number).padStart(2, '0')}</span></div>
<div className="shell player-top"><Link to={categoryUrl(video.categoryId)}>← Back to videos</Link><span>Video {String(number).padStart(2, '0')}</span></div>
<section className="shell player-grid">
<div>
<BasePlayer video={video} videoRef={baseRef} />
Expand Down
Loading
Loading