diff --git a/backend/.env.example b/backend/.env.example index c8eedaa..5d924ce 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -67,5 +67,10 @@ MAX_RECORDING_UPLOAD_MB=80 RATE_LIMIT_ENABLED=true +# Optional Upstash Redis REST credentials for caching public landing-page counts +# for 12 hours. Set both values to enable the cache. +# UPSTASH_REDIS_REST_URL=https://.upstash.io +# UPSTASH_REDIS_REST_TOKEN= + # Minimum gap, in milliseconds, between two submissions for the same (user, video). SUBMISSION_COOLDOWN_MS=30000 diff --git a/backend/docs/setup.md b/backend/docs/setup.md index fd54fc2..f5c3f44 100644 --- a/backend/docs/setup.md +++ b/backend/docs/setup.md @@ -144,6 +144,8 @@ shared credentials. | --- | --- | --- | | `RATE_LIMIT_ENABLED` | `true` or `false`; default `true` | Enables API and upload rate limits. Keep enabled outside local debugging. | | `SUBMISSION_COOLDOWN_MS` | Set manually; default `30000` | Minimum delay between submissions for the same user and reference video. | +| `UPSTASH_REDIS_REST_URL` | Upstash Redis database REST URL | Optional shared cache for public landing-page counts. Set together with `UPSTASH_REDIS_REST_TOKEN`. | +| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis database REST token; keep secret | Enables the public stats cache. Cached values expire after 12 hours; the API continues using MongoDB if Redis is unavailable. | | `WEB3FORMS_ACCESS_KEY` | Create an access key at Web3Forms | Required in production for contact-message delivery. | | `CONTACT_DAILY_LIMIT` | Set manually; default `25` | Maximum successfully delivered messages per UTC day. | | `CONTACT_USER_DAILY_LIMIT` | Set manually; default `1` | Maximum contact messages per account per UTC day. | diff --git a/backend/package-lock.json b/backend/package-lock.json index a3810b4..a99420e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@getbrevo/brevo": "^6.0.3", diff --git a/backend/package.json b/backend/package.json index af1fcfc..0b2ee17 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.0.1", "description": "SignPakCommons API: Express, Node.js and MongoDB", "main": "server.js", "scripts": { diff --git a/backend/src/config/env.js b/backend/src/config/env.js index ee3880d..2a2694c 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -80,7 +80,17 @@ const schema = z.object({ RATE_LIMIT_ENABLED: bool.default(true), SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), + UPSTASH_REDIS_REST_URL: z.string().url().optional(), + UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(), }).superRefine((data, context) => { + if (Boolean(data.UPSTASH_REDIS_REST_URL) !== Boolean(data.UPSTASH_REDIS_REST_TOKEN)) { + context.addIssue({ + code: 'custom', + path: ['UPSTASH_REDIS_REST_URL'], + message: 'Set both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to enable the Redis cache.', + }) + } + if (data.NODE_ENV === 'production') { if (!data.BREVO_API_KEY) { context.addIssue({ code: 'custom', path: ['BREVO_API_KEY'], message: 'Required in production for email verification and password reset.' }) diff --git a/backend/src/controllers/publicStatsController.js b/backend/src/controllers/publicStatsController.js new file mode 100644 index 0000000..2dbe68b --- /dev/null +++ b/backend/src/controllers/publicStatsController.js @@ -0,0 +1,7 @@ +import { publicStatsService } from '../services/publicStatsService.js' +import { asyncHandler } from '../utils/asyncHandler.js' +import { ok } from '../utils/response.js' + +export const publicStatsController = { + get: asyncHandler(async (req, res) => ok(res, await publicStatsService.get())), +} diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 78a7946..7304966 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -300,6 +300,23 @@ export const openapi = { }, }, }, + '/public/stats': { + get: { + tags: ['Public'], + summary: 'Get cached landing-page aggregate counts', + responses: { + 200: jsonResponse('Public stats', { + type: 'object', + required: ['contributors', 'publishedVideos', 'categories'], + properties: { + contributors: { type: 'integer', minimum: 0 }, + publishedVideos: { type: 'integer', minimum: 0 }, + categories: { type: 'integer', minimum: 0 }, + }, + }), + }, + }, + }, '/docs.json': { get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } }, }, diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 400b63d..3c7ad92 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -8,6 +8,7 @@ import authRoutes from './authRoutes.js' import categoryRoutes from './categoryRoutes.js' import contactRoutes from './contactRoutes.js' import demoVideoRoutes from './demoVideoRoutes.js' +import publicStatsRoutes from './publicStatsRoutes.js' import submissionRoutes from './submissionRoutes.js' import userRoutes from './userRoutes.js' import videoRoutes from './videoRoutes.js' @@ -34,6 +35,7 @@ router.use('/demo-video', demoVideoRoutes) router.use('/videos', videoRoutes) router.use('/submissions', submissionRoutes) router.use('/contact', contactRoutes) +router.use('/public', publicStatsRoutes) router.use('/admin', adminRoutes) export default router diff --git a/backend/src/routes/publicStatsRoutes.js b/backend/src/routes/publicStatsRoutes.js new file mode 100644 index 0000000..1fcb17a --- /dev/null +++ b/backend/src/routes/publicStatsRoutes.js @@ -0,0 +1,8 @@ +import { Router } from 'express' +import { publicStatsController } from '../controllers/publicStatsController.js' + +const router = Router() + +router.get('/stats', publicStatsController.get) + +export default router diff --git a/backend/src/services/cache/upstashRedis.js b/backend/src/services/cache/upstashRedis.js new file mode 100644 index 0000000..adbc7f8 --- /dev/null +++ b/backend/src/services/cache/upstashRedis.js @@ -0,0 +1,39 @@ +import { env } from '../../config/env.js' + +const configured = Boolean(env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) + +async function command(...args) { + const response = await fetch(env.UPSTASH_REDIS_REST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(args), + signal: AbortSignal.timeout(2500), + }) + if (!response.ok) throw new Error(`Upstash Redis request failed (${response.status}).`) + const payload = await response.json() + if (payload.error) throw new Error(`Upstash Redis command failed: ${payload.error}`) + return payload.result +} + +export const upstashRedis = { + get enabled() { + return configured && !env.isTest + }, + + async get(key) { + if (!this.enabled) return null + const result = await command('GET', key) + if (result !== null && typeof result !== 'string') { + throw new Error('Upstash Redis returned an invalid cache value.') + } + return result + }, + + async set(key, value, ttlSeconds) { + if (!this.enabled) return + await command('SET', key, JSON.stringify(value), 'EX', ttlSeconds) + }, +} diff --git a/backend/src/services/publicStatsService.js b/backend/src/services/publicStatsService.js new file mode 100644 index 0000000..82aa42d --- /dev/null +++ b/backend/src/services/publicStatsService.js @@ -0,0 +1,51 @@ +import { ROLES, VIDEO_STATUS } from '../config/constants.js' +import { env } from '../config/env.js' +import { categoryRepo } from '../repositories/categoryRepo.js' +import { userRepo } from '../repositories/userRepo.js' +import { videoRepo } from '../repositories/videoRepo.js' +import { logger } from '../utils/logger.js' +import { upstashRedis } from './cache/upstashRedis.js' + +const CACHE_KEY = 'signpak:public-stats:v1' +const CACHE_TTL_SECONDS = 12 * 60 * 60 + +function isValidStats(value) { + return value + && Number.isInteger(value.contributors) && value.contributors >= 0 + && Number.isInteger(value.publishedVideos) && value.publishedVideos >= 0 + && Number.isInteger(value.categories) && value.categories >= 0 +} + +async function loadStats() { + const [contributors, publishedVideos, categories] = await Promise.all([ + userRepo.countByRole(ROLES.USER), + videoRepo.count({ status: VIDEO_STATUS.PUBLISHED, category: { $ne: null } }), + categoryRepo.count(), + ]) + return { contributors, publishedVideos, categories } +} + +export const publicStatsService = { + async get() { + if (!upstashRedis.enabled) return loadStats() + + try { + const cached = await upstashRedis.get(CACHE_KEY) + if (cached !== null) { + const stats = JSON.parse(cached) + if (isValidStats(stats)) return stats + logger.warn('Ignoring invalid cached public stats from Upstash Redis.') + } + } catch (error) { + logger.warn({ err: error }, 'Could not read public stats from Upstash Redis; querying the database.') + } + + const stats = await loadStats() + try { + await upstashRedis.set(CACHE_KEY, stats, CACHE_TTL_SECONDS) + } catch (error) { + logger.warn({ err: error }, 'Could not cache public stats in Upstash Redis.') + } + return stats + }, +} diff --git a/backend/tests/api-basics.test.js b/backend/tests/api-basics.test.js index 93d5854..bdd5a26 100644 --- a/backend/tests/api-basics.test.js +++ b/backend/tests/api-basics.test.js @@ -33,6 +33,19 @@ describe('basics', () => { assert.deepEqual(res.body.data.database, 'connected') }) + test('public landing stats return aggregate counts without authentication', async () => { + const client = new Client(base) + const response = await client.get('/public/stats') + assert.equal(response.status, 200) + assert.deepEqual(Object.keys(response.body.data).sort(), ['categories', 'contributors', 'publishedVideos']) + for (const value of Object.values(response.body.data)) { + assert.ok(Number.isInteger(value) && value >= 0) + } + assert.equal(response.body.data.categories, (await client.get('/categories')).body.data.length) + assert.equal(response.body.data.publishedVideos, (await client.get('/videos')).body.data.length) + assert.ok(response.body.data.contributors >= 2) + }) + test('unknown routes and bad JSON use the error shape', async () => { const c = new Client(base) const missing = await c.get('/nope') diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dee19df..f79c360 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.1", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", diff --git a/frontend/package.json b/frontend/package.json index 967f1cb..5c318ed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.1", + "version": "1.0.3", "type": "module", "engines": { "node": ">=20.19.0" diff --git a/frontend/public/MCS_Library.jpg b/frontend/public/MCS_Library.jpg new file mode 100644 index 0000000..c2788b7 Binary files /dev/null and b/frontend/public/MCS_Library.jpg differ diff --git a/frontend/public/NUST_H-12.jpg b/frontend/public/NUST_H-12.jpg new file mode 100644 index 0000000..6e22667 Binary files /dev/null and b/frontend/public/NUST_H-12.jpg differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index e69de29..1f32448 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -0,0 +1,30 @@ + +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo5ODIxMjRkZi1kNTgzLTQ4YTQtYmM0Zi05ZDZiNGRkNGY1MTEAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaL92+VV9vQIYtY9CZZU+l3IAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo2ODE1Y2IzNi02YWYxLTRlM2QtODFmMC04ZWM0OGQ2ZDNkOGJscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo0kft0sLch+PshbLKpjwUnwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggCn4HygQgqrB081OBqmrCcsCoYWu3LLVx4JzebEodBImkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaJvOSElbBU+Yqj5HevVXwVcAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDrb3YH2ILFhU7atdDufZ4dn8r2ZIxuRqq3G8f6YTzaOWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBjxZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjk4MjEyNGRmLWQ1ODMtNDhhNC1iYzRmLTlkNmI0ZGQ0ZjUxMS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmU3ZTRjNjBjLWFmNWUtNGRhYy1iZmJmLTI2YjU0ZGJmOThjNHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAKfgfKBCCqsHTzU4GqasJywKhha7cstXHgnN5sSh0EiaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggrWhxTDP7n0GhPnFKp4TivRqjMvEhTzgTDrlsTbfARjSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggiOiblT1SD4TX4UMdIjrJ+pLJ0ynW+MzOTix6oCR4o6J0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQN1LS2xmfv6UwKwp4VCKMFHB0EFMwVsEgqJYZCIV/Mv4zQbFY/EVKgyirGPbP2AbUr+n1ToWOqftVQnkgpNIqVE= + + + + + diff --git a/frontend/src/components/AuthShell.jsx b/frontend/src/components/AuthShell.jsx index 6a04f89..659f2d8 100644 --- a/frontend/src/components/AuthShell.jsx +++ b/frontend/src/components/AuthShell.jsx @@ -4,19 +4,26 @@ import { Eyebrow } from './ui' /** Split layout shared by the login and signup pages. */ export default function AuthShell({ eyebrow, title, subtitle, children, footer }) { - return
-
-
Lorem ipsum dolor sit

Make room
for connection.

-
-
-
- ← Back home - {eyebrow} -

{title}

-

{subtitle}

- {children} - {footer &&

{footer}

} + return ( +
+
+
+ SIGN PAK +

+ Help us to make
communication easier. +

+
-
-
+
+
+ Back + {eyebrow} +

{title}

+

{subtitle}

+ {children} + {footer &&

{footer}

} +
+
+ + ) } diff --git a/frontend/src/components/BasePlayer.jsx b/frontend/src/components/BasePlayer.jsx index b368c20..5d02dc5 100644 --- a/frontend/src/components/BasePlayer.jsx +++ b/frontend/src/components/BasePlayer.jsx @@ -18,40 +18,73 @@ export default function BasePlayer({ video, videoRef }) { if (element.paused || element.ended) element.play().catch(() => {}) else element.pause() } + const replay = () => { const element = videoRef.current if (!element) return element.currentTime = 0 element.play().catch(() => {}) } - const seek = (event) => { videoRef.current.currentTime = Number(event.target.value) } + + const seek = (event) => { + videoRef.current.currentTime = Number(event.target.value) + } + const cycleSpeed = () => { const next = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length] videoRef.current.playbackRate = next setSpeed(next) } + const fullscreen = () => frame.current?.requestFullscreen?.() - return
-
-
-
- - - - {formatTime(time)} / {formatTime(duration)} - - + return ( +
+
+
+ +
+ + + + {formatTime(time)} / {formatTime(duration)} + + +
-
+ ) } diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 78bca57..b299e94 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -8,25 +8,80 @@ export default function Field({ label, error, hint, id, type, as: Control = 'inp const hintId = `${fieldId}-hint` const [passwordVisible, setPasswordVisible] = useState(false) const isPassword = Control === 'input' && type === 'password' - return
- - {isPassword - ?
- {children} - +
+ ) : ( + - {passwordVisible - ? - : } - -
- : {children}} - {error ?

{error}

: hint ?

{hint}

: null} -
+ {children} + + )} + + {error ? ( +

{error}

+ ) : hint ? ( +

{hint}

+ ) : null} + + ) } diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index a62b0c3..0c4cd7e 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -23,6 +23,7 @@ export default function Footer() {

Good to know

+ FAQ Policy Contact us
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 9b1279a..ec11614 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -6,33 +6,92 @@ import NotificationBell from './NotificationBell' import ThemeToggle from './ThemeToggle' import { Arrow, Avatar, Brand, ButtonLink } from './ui' -const publicLinks = [['How it works', paths.demo], ['About', paths.about], ['FAQ', paths.faq], ['Contact', paths.contact]] -const memberLinks = [['Library', paths.library], ['FAQ', paths.faq], ['My space', paths.profile]] +const publicLinks = [ + ['How it works', paths.demo], + ['About', paths.about], + ['Contact', paths.contact] +] + +const memberLinks = [ + ['Library', paths.library], + ['My space', paths.profile] +] export default function Header() { const { user, logout } = useAuth() const navigate = useNavigate() const [open, setOpen] = useState(false) const links = user ? memberLinks : publicLinks + const close = () => setOpen(false) - const handleLogout = async () => { close(); await logout(); navigate(paths.home) } + + const handleLogout = async () => { + close() + await logout() + navigate(paths.home) + } + + return ( +
+
+ + + - return
-
- - -
- - {user && } - {user - ? - : <>Log inGet started } - +
+ + + {user && } + + {user ? ( + + + + ) : ( + <> + + Log in + + + Get started + + + )} + + +
-
- {open && } -
+ + {open && ( + + )} +
+ ) } diff --git a/frontend/src/components/RecorderPanel.jsx b/frontend/src/components/RecorderPanel.jsx index b2d691b..ae80f1a 100644 --- a/frontend/src/components/RecorderPanel.jsx +++ b/frontend/src/components/RecorderPanel.jsx @@ -1,40 +1,94 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useRecorder } from '../hooks/useRecorder' import { formatTime } from '../utils/format' import { Alert, Arrow, Button } from './ui' /** Camera preview, record/stop, delete and re-record for one video. */ export default function RecorderPanel({ video, baseRef, onSaveAndEdit }) { - const { recording, status, error, elapsed, stream, start, stop, discard } = useRecorder(video.id) + const { recording, status, error, elapsed, stream, prepare, start, stop, discard } = useRecorder(video.id) const live = useRef(null) - useEffect(() => { if (live.current) live.current.srcObject = stream }, [stream]) + const countdownTimer = useRef(null) + const starting = useRef(false) + const [countdown, setCountdown] = useState(null) + + useEffect(() => { + if (live.current) live.current.srcObject = stream + }, [stream]) + + useEffect(() => () => clearTimeout(countdownTimer.current), []) const isRecording = status === 'recording' - const busy = status === 'requesting' + const busy = status === 'requesting' || countdown !== null + const showCameraPreview = isRecording || countdown !== null || (!recording && stream) // Recording starts the base video too, so contributors can sign along while watching. - const begin = async () => { + const startCapture = async () => { const started = await start() const base = baseRef.current if (started && base && (base.paused || base.ended)) base.play().catch(() => {}) } + const begin = async () => { + if (busy || starting.current) return + starting.current = true + const ready = await prepare() + if (!ready) { + starting.current = false + return + } + let remaining = 3 + setCountdown(remaining) + const tick = () => { + remaining -= 1 + if (remaining === 0) { + setCountdown(null) + starting.current = false + startCapture() + return + } + setCountdown(remaining) + countdownTimer.current = setTimeout(tick, 1000) + } + countdownTimer.current = setTimeout(tick, 1000) + } - return
-

{isRecording ? `Recording ${formatTime(elapsed)}` : recording ? 'Recording ready' : 'Your turn'}

-

Record
yourself.

-

Watch the example, then make it yours. You can review, trim and delete your recording before you submit it.

+ return ( +
+

+ + {isRecording ? `Recording ${formatTime(elapsed)}` : countdown !== null ? `Starting in ${countdown}…` : recording ? 'Recording ready' : 'Your turn'} +

+ +

Record
yourself.

+

Watch the example, then make it yours. You can review, trim and delete your recording before you submit it.

-
-
+
+
- {isRecording - ? - : } - {error && {error}} - {recording && !isRecording && } - -
+ {isRecording ? ( + + ) : ( + + )} + + {error && {error}} + {recording && !isRecording && } + + +
+ ) } diff --git a/frontend/src/components/RouteGuards.jsx b/frontend/src/components/RouteGuards.jsx index f153018..a145a68 100644 --- a/frontend/src/components/RouteGuards.jsx +++ b/frontend/src/components/RouteGuards.jsx @@ -1,10 +1,21 @@ +import { useEffect } from 'react' import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { Button, PageState } from './ui' import { useAuth } from '../context/AuthContext' export function RequireAuth() { - const { user, loading } = useAuth() + const { user, loading, sessionChecked, sessionError, ensureSession } = useAuth() const location = useLocation() - if (loading) return
Checking your session…
+ useEffect(() => { + if (!sessionChecked) void ensureSession() + }, [sessionChecked, ensureSession]) + + if (sessionError) return void ensureSession()}>Try again} + >{sessionError} + if (!sessionChecked || loading) return
Checking your session…
if (!user) return return } @@ -16,9 +27,18 @@ export function RequireAdmin() { /** Login and signup are for logged-out visitors. Anyone already in goes back to where they were headed. */ export function GuestOnly() { - const { user, loading } = useAuth() + const { user, loading, sessionChecked, sessionError, ensureSession } = useAuth() const location = useLocation() - if (loading) return
Checking your session…
+ useEffect(() => { + if (!sessionChecked) void ensureSession() + }, [sessionChecked, ensureSession]) + + if (sessionError) return void ensureSession()}>Try again} + >{sessionError} + if (!sessionChecked || loading) return
Checking your session…
if (user) return return } diff --git a/frontend/src/components/TrimSlider.jsx b/frontend/src/components/TrimSlider.jsx index a161971..124c822 100644 --- a/frontend/src/components/TrimSlider.jsx +++ b/frontend/src/components/TrimSlider.jsx @@ -1,13 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from 'react' import { formatTime } from '../utils/format' /** Two-handle range slider. Values are seconds; `total` is the full recording length. */ -export default function TrimSlider({ total, start, end, onChange }) { +export default function TrimSlider({ total, start, end, src, mirrored, onChange }) { + const frameVideo = useRef(null) + const frameCanvas = useRef(null) + const latestPreview = useRef(start) + const [previewTime, setPreviewTime] = useState(start) + const [showPreview, setShowPreview] = useState(false) + + const drawFrame = useCallback((video) => { + const canvas = frameCanvas.current + if (!canvas || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return + const context = canvas.getContext('2d') + if (!context) return + const scale = Math.min(canvas.width / video.videoWidth, canvas.height / video.videoHeight) + const width = video.videoWidth * scale + const height = video.videoHeight * scale + context.fillStyle = '#000' + context.fillRect(0, 0, canvas.width, canvas.height) + context.drawImage(video, (canvas.width - width) / 2, (canvas.height - height) / 2, width, height) + }, []) + + useEffect(() => { + latestPreview.current = previewTime + const video = frameVideo.current + if (!video || video.readyState < 1 || !Number.isFinite(video.duration)) return + const target = Math.min(Math.max(previewTime, 0), video.duration) + if (Math.abs(video.currentTime - target) > 0.08) video.currentTime = target + else drawFrame(video) + }, [previewTime, showPreview, drawFrame]) + + const onFrameMetadata = (event) => { + const video = event.currentTarget + if (video.duration === Infinity) { + video.currentTime = 1e101 + video.addEventListener('timeupdate', () => { + video.currentTime = latestPreview.current + }, { once: true }) + return + } + video.currentTime = Math.min(Math.max(latestPreview.current, 0), video.duration) + } + + const onFrameSeeked = (event) => { + const video = event.currentTarget + const target = Math.min(Math.max(latestPreview.current, 0), video.duration) + if (Math.abs(video.currentTime - target) > 0.08) { + video.currentTime = target + return + } + drawFrame(video) + } + + const updatePreview = (event) => { + const value = Number(event.currentTarget.value) + latestPreview.current = value + setPreviewTime(value) + setShowPreview(true) + } + const gap = Math.min(0.5, total / 2) - const left = (start / total) * 100 - const right = 100 - (end / total) * 100 - return
+ const safeTotal = Math.max(total, 0.1) + const left = (start / safeTotal) * 100 + const right = 100 - (end / safeTotal) * 100 + const previewPosition = (previewTime / safeTotal) * 100 + + return
setShowPreview(false)}> +
} diff --git a/frontend/src/components/ui.jsx b/frontend/src/components/ui.jsx deleted file mode 100644 index 963dd7d..0000000 --- a/frontend/src/components/ui.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Link } from 'react-router-dom' -import { initialsOf } from '../utils/format' -import { paths } from '../routes/appRoutes' - -const join = (...parts) => parts.filter(Boolean).join(' ') - -export function Brand({ light = false, to = paths.home }) { - return Ssignpak -} - -export function Eyebrow({ children, light = false }) { - return

{children}

-} - -export function Arrow() { - return -} - -export function Button({ children, variant = 'dark', block = false, className = '', type = 'button', ...props }) { - return -} - -export function ButtonLink({ to, children, variant = 'dark', block = false, className = '', ...props }) { - return {children} -} - -export function SectionHeading({ eyebrow, title, action }) { - return
{eyebrow}

{title}

{action}
-} - -export function VideoArtwork({ video, className = '', children }) { - return
{children}
-} - -export function Alert({ tone = 'info', children }) { - return

{children}

-} - -export function ProgressBar({ value, label }) { - const percent = Math.min(100, Math.max(0, value)) - return
-} - -export function Avatar({ user, size = 'md' }) { - return -} - -export function Badge({ tone = 'neutral', children }) { - return {children} -} - -/** Full-page message for empty, missing or blocked states. */ -export function PageState({ eyebrow, title, children, action }) { - return
{eyebrow}

{title}

{children}

{action}
-} diff --git a/frontend/src/components/ui/Alert.jsx b/frontend/src/components/ui/Alert.jsx new file mode 100644 index 0000000..d7599ac --- /dev/null +++ b/frontend/src/components/ui/Alert.jsx @@ -0,0 +1,7 @@ +export function Alert({ tone = 'info', children }) { + return ( +

+ {children} +

+ ) +} diff --git a/frontend/src/components/ui/Arrow.jsx b/frontend/src/components/ui/Arrow.jsx new file mode 100644 index 0000000..bf77022 --- /dev/null +++ b/frontend/src/components/ui/Arrow.jsx @@ -0,0 +1,5 @@ +export function Arrow() { + return ( + + ) +} diff --git a/frontend/src/components/ui/Avatar.jsx b/frontend/src/components/ui/Avatar.jsx new file mode 100644 index 0000000..4b24628 --- /dev/null +++ b/frontend/src/components/ui/Avatar.jsx @@ -0,0 +1,9 @@ +import { initialsOf } from '../../utils/format' + +export function Avatar({ user, size = 'md' }) { + return ( + + ) +} diff --git a/frontend/src/components/ui/Badge.jsx b/frontend/src/components/ui/Badge.jsx new file mode 100644 index 0000000..cfc3446 --- /dev/null +++ b/frontend/src/components/ui/Badge.jsx @@ -0,0 +1,7 @@ +export function Badge({ tone = 'neutral', children }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/components/ui/Brand.jsx b/frontend/src/components/ui/Brand.jsx new file mode 100644 index 0000000..fe2d1a2 --- /dev/null +++ b/frontend/src/components/ui/Brand.jsx @@ -0,0 +1,14 @@ +import { Link } from 'react-router-dom' +import { join } from './join' +import { paths } from '../../routes/appRoutes' + +export function Brand({ light = false, to = paths.home }) { + return ( + + + signpak + + ) +} diff --git a/frontend/src/components/ui/Button.jsx b/frontend/src/components/ui/Button.jsx new file mode 100644 index 0000000..bd4985e --- /dev/null +++ b/frontend/src/components/ui/Button.jsx @@ -0,0 +1,9 @@ +import { join } from './join' + +export function Button({ children, variant = 'dark', block = false, className = '', type = 'button', ...props }) { + return ( + + ) +} diff --git a/frontend/src/components/ui/ButtonLink.jsx b/frontend/src/components/ui/ButtonLink.jsx new file mode 100644 index 0000000..e6b5b45 --- /dev/null +++ b/frontend/src/components/ui/ButtonLink.jsx @@ -0,0 +1,10 @@ +import { Link } from 'react-router-dom' +import { join } from './join' + +export function ButtonLink({ to, children, variant = 'dark', block = false, className = '', ...props }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/components/ui/Eyebrow.jsx b/frontend/src/components/ui/Eyebrow.jsx new file mode 100644 index 0000000..e16dbe2 --- /dev/null +++ b/frontend/src/components/ui/Eyebrow.jsx @@ -0,0 +1,9 @@ +import { join } from './join' + +export function Eyebrow({ children, light = false }) { + return ( +

+ {children} +

+ ) +} diff --git a/frontend/src/components/ui/PageState.jsx b/frontend/src/components/ui/PageState.jsx new file mode 100644 index 0000000..34d5654 --- /dev/null +++ b/frontend/src/components/ui/PageState.jsx @@ -0,0 +1,13 @@ +import { Eyebrow } from './Eyebrow' + +/** Full-page message for empty, missing or blocked states. */ +export function PageState({ eyebrow, title, children, action }) { + return ( +
+ {eyebrow} +

{title}

+

{children}

+ {action} +
+ ) +} diff --git a/frontend/src/components/ui/ProgressBar.jsx b/frontend/src/components/ui/ProgressBar.jsx new file mode 100644 index 0000000..7123c63 --- /dev/null +++ b/frontend/src/components/ui/ProgressBar.jsx @@ -0,0 +1,15 @@ +export function ProgressBar({ value, label }) { + const percent = Math.min(100, Math.max(0, value)) + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/ui/SectionHeading.jsx b/frontend/src/components/ui/SectionHeading.jsx new file mode 100644 index 0000000..2e4e99e --- /dev/null +++ b/frontend/src/components/ui/SectionHeading.jsx @@ -0,0 +1,13 @@ +import { Eyebrow } from './Eyebrow' + +export function SectionHeading({ eyebrow, title, action }) { + return ( +
+
+ {eyebrow} +

{title}

+
+ {action} +
+ ) +} diff --git a/frontend/src/components/ui/VideoArtwork.jsx b/frontend/src/components/ui/VideoArtwork.jsx new file mode 100644 index 0000000..93f2bfe --- /dev/null +++ b/frontend/src/components/ui/VideoArtwork.jsx @@ -0,0 +1,12 @@ +import { join } from './join' + +export function VideoArtwork({ video, className = '', children }) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/ui/index.js b/frontend/src/components/ui/index.js new file mode 100644 index 0000000..016e68f --- /dev/null +++ b/frontend/src/components/ui/index.js @@ -0,0 +1,12 @@ +export { Brand } from './Brand' +export { Eyebrow } from './Eyebrow' +export { Arrow } from './Arrow' +export { Button } from './Button' +export { ButtonLink } from './ButtonLink' +export { SectionHeading } from './SectionHeading' +export { VideoArtwork } from './VideoArtwork' +export { Alert } from './Alert' +export { ProgressBar } from './ProgressBar' +export { Avatar } from './Avatar' +export { Badge } from './Badge' +export { PageState } from './PageState' diff --git a/frontend/src/components/ui/join.js b/frontend/src/components/ui/join.js new file mode 100644 index 0000000..9c82f0b --- /dev/null +++ b/frontend/src/components/ui/join.js @@ -0,0 +1 @@ +export const join = (...parts) => parts.filter(Boolean).join(' ') diff --git a/frontend/src/context/AuthProvider.jsx b/frontend/src/context/AuthProvider.jsx index 1b0e8e8..7aef4f9 100644 --- a/frontend/src/context/AuthProvider.jsx +++ b/frontend/src/context/AuthProvider.jsx @@ -1,45 +1,67 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { api } from '../services/api' import { AuthContext } from './AuthContext' export default function AuthProvider({ children }) { const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(false) + const [sessionChecked, setSessionChecked] = useState(false) + const [sessionError, setSessionError] = useState('') + const sessionRequest = useRef(null) - const refreshSession = useCallback(async () => { - try { - setUser(await api.auth.session()) - } catch { - setUser(null) - } finally { - setLoading(false) - } - }, []) + const ensureSession = useCallback(() => { + if (sessionChecked && !sessionError) return Promise.resolve(user) + if (sessionRequest.current) return sessionRequest.current - useEffect(() => { - const initialRefresh = window.setTimeout(refreshSession, 0) - window.addEventListener('storage', refreshSession) - window.addEventListener('signpak:auth-change', refreshSession) - return () => { - window.clearTimeout(initialRefresh) - window.removeEventListener('storage', refreshSession) - window.removeEventListener('signpak:auth-change', refreshSession) - } - }, [refreshSession]) + setLoading(true) + setSessionError('') + const request = api.auth.session() + .then((session) => { + setUser(session) + return session + }).catch((error) => { + setUser(null) + setSessionError(error?.message || 'Unable to check your session. Try again.') + return null + }) + .finally(() => { + sessionRequest.current = null + setSessionChecked(true) + setLoading(false) + }) + sessionRequest.current = request + return request + }, [sessionChecked, sessionError, user]) - const login = useCallback(async (email, password) => setUser(await api.auth.login(email, password)), []) + const login = useCallback(async (email, password) => { + const nextUser = await api.auth.login(email, password) + setUser(nextUser) + setSessionChecked(true) + setSessionError('') + return nextUser + }, []) const signup = useCallback((values) => api.auth.register(values), []) - const verifyEmail = useCallback(async (email, code) => setUser(await api.auth.verifyEmail(email, code)), []) + const verifyEmail = useCallback(async (email, code) => { + const nextUser = await api.auth.verifyEmail(email, code) + setUser(nextUser) + setSessionChecked(true) + setSessionError('') + return nextUser + }, []) const logout = useCallback(async () => { - try { await api.auth.logout() } finally { setUser(null) } + try { await api.auth.logout() } finally { + setUser(null) + setSessionChecked(true) + setSessionError('') + } }, []) const saveConnections = useCallback(async (connections) => { if (user) setUser(await api.auth.saveConnections(user.id, connections)) }, [user]) const value = useMemo( - () => ({ user, loading, isAdmin: user?.role === 'admin', login, signup, verifyEmail, logout, saveConnections }), - [user, loading, login, signup, verifyEmail, logout, saveConnections], + () => ({ user, loading, sessionChecked, sessionError, ensureSession, isAdmin: user?.role === 'admin', login, signup, verifyEmail, logout, saveConnections }), + [user, loading, sessionChecked, sessionError, ensureSession, login, signup, verifyEmail, logout, saveConnections], ) return {children} } diff --git a/frontend/src/context/LibraryProvider.jsx b/frontend/src/context/LibraryProvider.jsx index acbedc7..a197545 100644 --- a/frontend/src/context/LibraryProvider.jsx +++ b/frontend/src/context/LibraryProvider.jsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' import { Button, PageState } from '../components/ui' import { api } from '../services/api' +import { paths } from '../routes/appRoutes' import { useAuth } from './AuthContext' import { LibraryContext } from './LibraryContext' -const EMPTY = { ready: false, categories: [], videos: [], submissions: [] } +const EMPTY = { ready: false, ownerId: null, categories: [], videos: [], submissions: [] } const SUBMISSION_COOLDOWN_MS = Number(import.meta.env.VITE_SUBMISSION_COOLDOWN_MS || 30_000) function loadLibrary(user) { @@ -16,32 +18,61 @@ function loadLibrary(user) { ]) } +function isLibraryRoute(pathname) { + return pathname === paths.library + || pathname.startsWith('/library/') + || pathname === paths.watch + || pathname === paths.watchEdit + || pathname === paths.profile + || pathname === paths.admin + || pathname.startsWith(`${paths.admin}/`) +} + export default function LibraryProvider({ children }) { const { user } = useAuth() + const location = useLocation() const [data, setData] = useState(EMPTY) - const [loadError, setLoadError] = useState('') + const [loadError, setLoadError] = useState(null) const [loadAttempt, setLoadAttempt] = useState(0) + const inFlight = useRef(new Map()) + const shouldLoad = Boolean(user) && isLibraryRoute(location.pathname) + const userId = user?.id const refresh = useCallback(async () => { + if (!user) throw new Error('You must be logged in to load the library.') const [categories, videos, submissions] = await loadLibrary(user) - setData({ ready: true, categories, videos, submissions }) - setLoadError('') + setData({ ready: true, ownerId: user.id, categories, videos, submissions }) + setLoadError(null) }, [user]) useEffect(() => { + if (!user || !shouldLoad || (data.ready && data.ownerId === user.id)) return undefined + let active = true - loadLibrary(user) + let request = inFlight.current.get(user.id) + if (!request) { + request = loadLibrary(user) + inFlight.current.set(user.id, request) + const clearRequest = () => { + if (inFlight.current.get(user.id) === request) inFlight.current.delete(user.id) + } + request.then(clearRequest, clearRequest) + } + request .then(([categories, videos, submissions]) => { if (active) { - setData({ ready: true, categories, videos, submissions }) - setLoadError('') + setData({ ready: true, ownerId: user.id, categories, videos, submissions }) + setLoadError(null) } }) .catch((error) => { - if (active) setLoadError(error?.message || 'Unable to load the library. Try again.') + if (active) setLoadError({ + userId: user.id, + message: error?.message || 'Unable to load the library. Try again.', + }) }) return () => { active = false } - }, [user, loadAttempt]) + }, [data.ownerId, data.ready, loadAttempt, shouldLoad, user, userId]) const value = useMemo(() => { const { categories, videos, submissions } = data @@ -98,16 +129,17 @@ export default function LibraryProvider({ children }) { } }, [data, user, refresh]) - if (!data.ready && loadError) { + const dataReadyForUser = Boolean(user) && data.ready && data.ownerId === user.id + if (shouldLoad && !dataReadyForUser && loadError?.userId === user.id) { return { - setLoadError('') + setLoadError(null) setLoadAttempt((attempt) => attempt + 1) }}>Try again} - >{loadError} + >{loadError.message} } - if (!data.ready) return
Loading your library…
+ if (shouldLoad && !dataReadyForUser) return
Loading your library…
return {children} } diff --git a/frontend/src/context/RecordingProvider.jsx b/frontend/src/context/RecordingProvider.jsx index 0ee445b..941f159 100644 --- a/frontend/src/context/RecordingProvider.jsx +++ b/frontend/src/context/RecordingProvider.jsx @@ -13,6 +13,10 @@ export default function RecordingProvider({ children }) { const { user } = useAuth() const [recordings, setRecordings] = useState({}) const latest = useRef(recordings) + const cameraStreamRef = useRef(null) + const cameraRequest = useRef(null) + const cameraEpoch = useRef(0) + const [cameraStream, setCameraStream] = useState(null) useEffect(() => { latest.current = recordings }, [recordings]) const saveRecording = useCallback((videoId, { blob, duration, mimeType }) => { @@ -39,13 +43,60 @@ export default function RecordingProvider({ children }) { : current)) }, []) + const requestCamera = useCallback(() => { + const existing = cameraStreamRef.current + if (existing?.getVideoTracks().some((track) => track.readyState === 'live')) { + return Promise.resolve(existing) + } + if (cameraRequest.current) return cameraRequest.current + + const epoch = cameraEpoch.current + const request = navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, + audio: false, + }).then((stream) => { + if (epoch !== cameraEpoch.current) { + stream.getTracks().forEach((track) => track.stop()) + throw new Error('Camera request was cancelled.') + } + cameraStreamRef.current = stream + setCameraStream(stream) + stream.getVideoTracks().forEach((track) => { + track.addEventListener('ended', () => { + if (cameraStreamRef.current !== stream) return + cameraStreamRef.current = null + setCameraStream(null) + }, { once: true }) + }) + return stream + }).finally(() => { + if (cameraRequest.current === request) cameraRequest.current = null + }) + cameraRequest.current = request + return request + }, []) + + const releaseCamera = useCallback((stream = cameraStreamRef.current) => { + if (!stream) return + stream.getTracks().forEach((track) => track.stop()) + if (cameraStreamRef.current === stream) { + cameraStreamRef.current = null + setCameraStream(null) + } + }, []) + // Different person logged in on this tab: drop whatever the previous one recorded. const userId = user?.id useEffect(() => () => { + cameraEpoch.current += 1 + cameraRequest.current = null + cameraStreamRef.current?.getTracks().forEach((track) => track.stop()) + cameraStreamRef.current = null + setCameraStream(null) Object.values(latest.current).forEach(revoke) setRecordings({}) }, [userId]) - const value = useMemo(() => ({ recordings, saveRecording, removeRecording, updateEdit }), [recordings, saveRecording, removeRecording, updateEdit]) + const value = useMemo(() => ({ recordings, saveRecording, removeRecording, updateEdit, cameraStream, requestCamera, releaseCamera }), [recordings, saveRecording, removeRecording, updateEdit, cameraStream, requestCamera, releaseCamera]) return {children} } diff --git a/frontend/src/hooks/useRecorder.js b/frontend/src/hooks/useRecorder.js index 481c87e..aecd211 100644 --- a/frontend/src/hooks/useRecorder.js +++ b/frontend/src/hooks/useRecorder.js @@ -13,26 +13,50 @@ function describeError(error) { /** Camera + MediaRecorder for one video. The finished take is stored in RecordingContext. */ export function useRecorder(videoId) { - const { recordings, saveRecording, removeRecording } = useRecordings() + const { recordings, saveRecording, removeRecording, cameraStream, requestCamera, releaseCamera } = useRecordings() const [status, setStatus] = useState('idle') // idle | requesting | recording const [error, setError] = useState('') const [elapsed, setElapsed] = useState(0) - const [stream, setStream] = useState(null) + const preparedStream = useRef(null) const recorder = useRef(null) const chunks = useRef([]) const timer = useRef(null) const startedAt = useRef(0) const cancelled = useRef(false) - const start = useCallback(async () => { + const prepare = useCallback(async () => { if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { setError('Recording is not supported in this browser. Try a recent version of Chrome, Edge, Firefox or Safari.') return false } + cancelled.current = false setError('') setStatus('requesting') try { - const media = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: false }) + const media = await requestCamera() + if (cancelled.current) { + releaseCamera(media) + return false + } + preparedStream.current = media + setStatus('idle') + return true + } catch (caught) { + if (cancelled.current) return false + setError(describeError(caught)) + setStatus('idle') + return false + } + }, [releaseCamera, requestCamera]) + + const start = useCallback(async () => { + const media = preparedStream.current || cameraStream + if (!media?.getVideoTracks().some((track) => track.readyState === 'live')) { + setError('Camera access is no longer available. Allow camera access and try again.') + return false + } + setError('') + try { const mimeType = pickMimeType() const instance = new MediaRecorder(media, mimeType ? { mimeType } : undefined) chunks.current = [] @@ -40,12 +64,13 @@ export function useRecorder(videoId) { instance.ondataavailable = (event) => { if (event.data.size) chunks.current.push(event.data) } instance.onstop = () => { clearInterval(timer.current) - media.getTracks().forEach((track) => track.stop()) - setStream(null) if (!cancelled.current && chunks.current.length) { const type = instance.mimeType || mimeType || 'video/webm' saveRecording(videoId, { blob: new Blob(chunks.current, { type }), duration: Math.max((Date.now() - startedAt.current) / 1000, 0.5), mimeType: type }) } + releaseCamera(media) + preparedStream.current = null + recorder.current = null setStatus('idle') } recorder.current = instance @@ -53,15 +78,16 @@ export function useRecorder(videoId) { setElapsed(0) timer.current = setInterval(() => setElapsed((Date.now() - startedAt.current) / 1000), 250) instance.start(250) - setStream(media) setStatus('recording') return true } catch (caught) { + releaseCamera(media) + preparedStream.current = null setError(describeError(caught)) setStatus('idle') return false } - }, [saveRecording, videoId]) + }, [cameraStream, releaseCamera, saveRecording, videoId]) const stop = useCallback(() => { if (recorder.current?.state === 'recording') recorder.current.stop() @@ -72,12 +98,14 @@ export function useRecorder(videoId) { removeRecording(videoId) }, [removeRecording, videoId]) - // Leaving the page mid-take throws the take away and releases the camera. + // Leave no active recording or camera stream behind when navigating away. useEffect(() => () => { cancelled.current = true if (recorder.current?.state === 'recording') recorder.current.stop() clearInterval(timer.current) - }, []) + releaseCamera(preparedStream.current) + preparedStream.current = null + }, [releaseCamera]) - return { recording: recordings[videoId] || null, status, error, elapsed, stream, start, stop, discard } + return { recording: recordings[videoId] || null, status, error, elapsed, stream: cameraStream, prepare, start, stop, discard } } diff --git a/frontend/src/pages/CategoryBrowser.jsx b/frontend/src/pages/CategoryBrowser.jsx index 5e3f4d8..a0c62ed 100644 --- a/frontend/src/pages/CategoryBrowser.jsx +++ b/frontend/src/pages/CategoryBrowser.jsx @@ -26,41 +26,88 @@ export default function CategoryBrowser() { .sort(SORTS[sort].compare) if (!category) { - return Back to library}>It may have been renamed or removed. + return ( + Back to library} + > + It may have been renamed or removed. + + ) } - return
- -
-
Category · {all.length} {all.length === 1 ? 'video' : 'videos'}

{category.label}
starts here.

-

{category.copy}

-
+ return ( +
+ -
- - +
+
+ Category · {all.length} {all.length === 1 ? 'video' : 'videos'} +

{category.label}
starts here.

- } /> - +

{category.copy}

+
- {videos.length - ?
- {videos.map((video) => { - const count = submissionCount(video.id) - return - - {String(all.indexOf(video) + 1).padStart(2, '0')} - {hasSubmission(video.id) && ✓ {count} submitted} - - -

{formatTime(video.durationSec)}

-

{video.title}

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

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

} -
-
+
+ + +
+ } + /> + + + + {videos.length ? ( +
+ {videos.map((video) => { + const count = submissionCount(video.id) + return ( + + + {String(all.indexOf(video) + 1).padStart(2, '0')} + {hasSubmission(video.id) && ( + + ✓ {count} submitted + + )} + + +

{formatTime(video.durationSec)}

+

{video.title}

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

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

+ )} + + + ) } diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx index 2c4ce0b..ec4ed12 100644 --- a/frontend/src/pages/ForgotPassword.jsx +++ b/frontend/src/pages/ForgotPassword.jsx @@ -22,7 +22,10 @@ export default function ForgotPassword() { const requestCode = async (event) => { event.preventDefault() setError('') - if (!isEmail(email)) { setError('Enter a valid email address.'); return } + if (!isEmail(email)) { + setError('Enter a valid email address.') + return + } setBusy(true) try { const result = await api.auth.forgotPassword(email) @@ -38,9 +41,18 @@ export default function ForgotPassword() { const reset = async (event) => { event.preventDefault() setError('') - if (code.length !== 6) { setError('Enter the six-digit code from your email.'); return } - if (password.length < 8) { setError('Use at least 8 characters for your password.'); return } - if (password !== confirmPassword) { setError('Passwords do not match.'); return } + if (code.length !== 6) { + setError('Enter the six-digit code from your email.') + return + } + if (password.length < 8) { + setError('Use at least 8 characters for your password.') + return + } + if (password !== confirmPassword) { + setError('Passwords do not match.') + return + } setBusy(true) try { await api.auth.resetPassword({ email, code, password, confirmPassword }) @@ -64,27 +76,73 @@ export default function ForgotPassword() { } } - return Remembered it? Log in} - > - {!resetting - ?
- setEmail(event.target.value)} autoComplete="email" /> - {error && {error}} - - - :
- setEmail(event.target.value)} autoComplete="email" /> - setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} /> - setPassword(event.target.value)} hint="Use at least 8 characters." /> - setConfirmPassword(event.target.value)} /> - {notice && {notice}} - {error && {error}} - - - } -
+ return ( + Remembered it? Log in} + > + {!resetting ? ( +
+ setEmail(event.target.value)} + autoComplete="email" + /> + {error && {error}} + + + ) : ( +
+ setEmail(event.target.value)} + autoComplete="email" + /> + setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} + /> + setPassword(event.target.value)} + hint="Use at least 8 characters." + /> + setConfirmPassword(event.target.value)} + /> + {notice && {notice}} + {error && {error}} + + + + )} +
+ ) } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 32b5b33..76c6638 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -13,43 +13,71 @@ export default function Home() { const nextCategory = nextUp && categories.find((item) => item.id === nextUp.categoryId) const visibleCategories = categories.filter((item) => videosIn(item.id).length) - return
-
-
- {today} -

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

-

Which video would you like to contribute to today?

-
-
- -
- {nextUp ? <> + return ( +
+
- Keep contributing ✦ -

{nextUp.title}

-

{nextCategory?.label}

-
{contributedCount} of {orderedPublished.length} videos have a recording from you
+ {today} +

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

+

Which video would you like to contribute to today?

- ▶ - :
{orderedPublished.length ? 'Every video covered ✦' : 'Nothing here yet'}

{orderedPublished.length ? 'You have recorded every video at least once.' : 'Videos are on their way.'}

{orderedPublished.length ? 'You can still record any of them again — new categories will also appear here when they are published.' : 'Check back soon.'}

} -
+
-
- - {visibleCategories.length - ?
- {visibleCategories.map((category, index) => { - const videos = videosIn(category.id) - const contributed = videos.filter((video) => hasSubmission(video.id)).length - return - {String(index + 1).padStart(2, '0')} - -

{category.label}

{category.copy}

- {videos.length} {videos.length === 1 ? 'video' : 'videos'} · {contributed} covered +
+ {nextUp ? ( + <> +
+ Keep contributing ✦ +

{nextUp.title}

+

{nextCategory?.label}

+
+ + {contributedCount} of {orderedPublished.length} videos have a recording from you +
+
+ + ▶ - })} -
- :

No categories have published videos yet. Check back soon.

} -
-
+ + ) : ( +
+ {orderedPublished.length ? 'Every video covered ✦' : 'Nothing here yet'} +

+ {orderedPublished.length ? 'You have recorded every video at least once.' : 'Videos are on their way.'} +

+

+ {orderedPublished.length ? 'You can still record any of them again — new categories will also appear here when they are published.' : 'Check back soon.'} +

+
+ )} + + +
+ + {visibleCategories.length ? ( +
+ {visibleCategories.map((category, index) => { + const videos = videosIn(category.id) + const contributed = videos.filter((video) => hasSubmission(video.id)).length + return ( + + {String(index + 1).padStart(2, '0')} + +
+

{category.label}

+

{category.copy}

+
+ {videos.length} {videos.length === 1 ? 'video' : 'videos'} · {contributed} covered + + ) + })} +
+ ) : ( +

No categories have published videos yet. Check back soon.

+ )} +
+ + ) } diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx index 8b24411..1fbb781 100644 --- a/frontend/src/pages/Landing.jsx +++ b/frontend/src/pages/Landing.jsx @@ -1,6 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' -import { useLibrary } from '../context/LibraryContext' import Field from '../components/Field' import Footer from '../components/Footer' import { Alert, Arrow, Button, ButtonLink, Eyebrow } from '../components/ui' @@ -46,7 +46,22 @@ const contributorPerks = [ export default function Landing() { const { user } = useAuth() - const { publishedVideos, categories } = useLibrary() + const [stats, setStats] = useState(null) + const [statsError, setStatsError] = useState(false) + + useEffect(() => { + let active = true + api.public.stats() + .then((result) => { + if (!active) return + setStats(result) + setStatsError(false) + }) + .catch(() => { + if (active) setStatsError(true) + }) + return () => { active = false } + }, []) return (
@@ -75,7 +90,7 @@ export default function Landing() {

-
+
“Every sign contributed brings the distance to an end.” signpak @@ -86,7 +101,20 @@ export default function Landing() { {/* MARQUEE BAND */} {/* ABOUT SECTION */} @@ -105,20 +133,21 @@ export default function Landing() {

By contributing short video clips of signs, you directly help train and fine-tune machine learning models, moving PSL technology beyond isolated letters toward real-world communication.

-
+
-
signs cataloged
-
{publishedVideos.length}
+
registered contributors
+
{stats?.contributors ?? '—'}
-
categories
-
{categories.length}
+
published videos
+
{stats?.publishedVideos ?? '—'}
-
community impact
-
∞
+
categories
+
{stats?.categories ?? '—'}
+ {statsError &&

Community counts are temporarily unavailable.

}
@@ -171,7 +200,7 @@ export default function Landing() { {user ? 'Open library' : 'Create your account'}
-
+
{/* CONTACT SECTION */} @@ -185,9 +214,9 @@ export default function Landing() {

Have questions about video contributions, recording specifications, or partnership opportunities? Reach out to our team.

- {/* - contact@pslai.org - */} +

+ Looking for a quick answer? Visit our FAQ page. +

diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index fc6b816..4d8141f 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -14,6 +14,7 @@ export default function Login() { const [errors, setErrors] = useState({}) const [formError, setFormError] = useState('') const [busy, setBusy] = useState(false) + const change = (event) => setValues((current) => ({ ...current, [event.target.name]: event.target.value })) const submit = async (event) => { @@ -23,20 +24,55 @@ export default function Login() { if (Object.keys(found).length) return setBusy(true) setFormError('') - try { await login(values.email, values.password) } - catch (error) { setFormError(error.message); setBusy(false) } + try { + await login(values.email, values.password) + } catch (error) { + setFormError(error.message) + setBusy(false) + } } - return( - New to Signpak? Create an account}> -
- - -

Forgot password? · Verify email

- {location.state?.notice && {location.state.notice}} - {formError && {formError}} - - -
- ); + return ( + + New to Signpak? Create an account + + } + > +
+ + +

+ Forgot password? · Verify email +

+ {location.state?.notice && {location.state.notice}} + {formError && {formError}} + + +
+ ) } diff --git a/frontend/src/pages/Player.jsx b/frontend/src/pages/Player.jsx index 40a2d1d..77093e9 100644 --- a/frontend/src/pages/Player.jsx +++ b/frontend/src/pages/Player.jsx @@ -10,6 +10,7 @@ import { safeRouteId } from '../utils/validators' export default function Player() { const [params] = useSearchParams() const videoId = safeRouteId(params.get('v')) + // Keyed so switching videos (Next video, another category) resets the player and the camera. return } @@ -18,23 +19,36 @@ export default function Player() { function useCooldown(lastAt, cooldownMs) { const compute = () => (lastAt ? Math.max(0, cooldownMs - (Date.now() - lastAt)) : 0) const [remaining, setRemaining] = useState(compute) + useEffect(() => { if (!lastAt) return undefined const id = setInterval(() => setRemaining(compute()), 250) return () => clearInterval(id) // eslint-disable-next-line react-hooks/exhaustive-deps -- `compute` is intentionally re-created each render; only lastAt/cooldownMs should restart the interval }, [lastAt, cooldownMs]) + return remaining } function PlayerScreen({ videoId }) { - const { publishedVideos, categories, videosIn, submissionCount, lastSubmissionAt, nextVideo, cooldownMs, refresh } = useLibrary() + const { + publishedVideos, + categories, + videosIn, + submissionCount, + lastSubmissionAt, + nextVideo, + cooldownMs, + refresh + } = useLibrary() + const navigate = useNavigate() const location = useLocation() const baseRef = useRef(null) const video = publishedVideos.find((item) => item.id === videoId) const [justSubmitted, setJustSubmitted] = useState(Boolean(location.state?.justSubmitted)) + useEffect(() => { if (!justSubmitted) return undefined const timer = setTimeout(() => setJustSubmitted(false), 5000) @@ -44,13 +58,26 @@ function PlayerScreen({ videoId }) { const remaining = useCooldown(video ? lastSubmissionAt(video.id) : null, cooldownMs) // The countdown only ever counts down, so a fresh 0 -> refresh library data to reflect the new submission in badges/counts. const wasCoolingDown = useRef(false) + useEffect(() => { - if (remaining > 0) wasCoolingDown.current = true - else if (wasCoolingDown.current) { wasCoolingDown.current = false; refresh() } + if (remaining > 0) { + wasCoolingDown.current = true + } else if (wasCoolingDown.current) { + wasCoolingDown.current = false + refresh() + } }, [remaining, refresh]) if (!video) { - return Back to library}>It may have been unpublished or removed. + return ( + Back to library} + > + It may have been unpublished or removed. + + ) } const category = categories.find((item) => item.id === video.categoryId) @@ -60,34 +87,78 @@ function PlayerScreen({ videoId }) { const goNext = () => navigate(next ? watchUrl(next.id) : categoryUrl(video.categoryId)) const seconds = Math.ceil(remaining / 1000) - return
-
← Back to videosVideo {String(number).padStart(2, '0')}
-
-
- -
-
{category?.label}

{video.title}

- -
- {justSubmitted && Recording received — thank you for contributing.} + return ( +
+
+ ← Back to videos + Video {String(number).padStart(2, '0')}
- -
-
+ + {justSubmitted && ( + + Recording received — thank you for contributing. + + )} +
+ + + + + ) } diff --git a/frontend/src/pages/ProfileDashboard.jsx b/frontend/src/pages/ProfileDashboard.jsx index 77549f9..170e61c 100644 --- a/frontend/src/pages/ProfileDashboard.jsx +++ b/frontend/src/pages/ProfileDashboard.jsx @@ -10,78 +10,140 @@ import { normalizeGithub, normalizeLinkedin } from '../utils/validators' export default function ProfileDashboard() { const { user, isAdmin, logout } = useAuth() - const { categories, videosIn, hasSubmission, mySubmissions, publishedVideos, orderedPublished, contributedCount, totalSubmissionCount } = useLibrary() + const { + categories, + videosIn, + hasSubmission, + mySubmissions, + publishedVideos, + orderedPublished, + contributedCount, + totalSubmissionCount + } = useLibrary() const navigate = useNavigate() const total = orderedPublished.length const percent = total ? Math.round((contributedCount / total) * 100) : 0 - const recent = [...mySubmissions].filter((item) => publishedVideos.some((video) => video.id === item.videoId)).sort((a, b) => b.submittedAt - a.submittedAt) + const recent = [...mySubmissions] + .filter((item) => publishedVideos.some((video) => video.id === item.videoId)) + .sort((a, b) => b.submittedAt - a.submittedAt) - return
-
-
- Your space -

{user.firstName} {user.surname}.

-

{user.email} · Contributing since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}

- -
- -
- -
-
-
- {contributedCount}/{total}} /> - -
Videos covered · {totalSubmissionCount} {totalSubmissionCount === 1 ? 'recording' : 'recordings'} submitted in total{percent}%
-
    - {categories.filter((category) => videosIn(category.id).length).map((category) => { - const videos = videosIn(category.id) - const contributed = videos.filter((video) => hasSubmission(video.id)).length - return
  • {category.label}{contributed}/{videos.length}
  • - })} -
- Keep contributing + return ( +
+
+
+ Your space +

{user.firstName} {user.surname}.

+

+ {user.email} · Contributing since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +

+
+ +
-
- Your recordings -

Recent submissions.

- {recent.length - ?
    - {recent.map((item) => { - const video = publishedVideos.find((entry) => entry.id === item.videoId) - return
  • {video.title}Submitted {formatDate(item.submittedAt)}
    Submitted
  • +
    +
    +
    + {contributedCount}/{total}} + /> + +
    + Videos covered · {totalSubmissionCount} {totalSubmissionCount === 1 ? 'recording' : 'recordings'} submitted in total + {percent}% +
    +
      + {categories.filter((category) => videosIn(category.id).length).map((category) => { + const videos = videosIn(category.id) + const contributed = videos.filter((video) => hasSubmission(video.id)).length + return ( +
    • + {category.label} + + {contributed}/{videos.length} +
    • + ) })}
    - :

    Your submitted recordings will show up here.

    } + + Keep contributing + +
    + +
    + Your recordings +

    Recent submissions.

    + {recent.length ? ( +
      + {recent.map((item) => { + const video = publishedVideos.find((entry) => entry.id === item.videoId) + return ( +
    • + +
      + {video.title} + Submitted {formatDate(item.submittedAt)} +
      + Submitted +
    • + ) + })} +
    + ) : ( +

    Your submitted recordings will show up here.

    + )} +
    -
- -
-
+ + + + ) } function ConnectForm() { const { user, saveConnections } = useAuth() - const [values, setValues] = useState({ github: user.connections?.github || '', linkedin: user.connections?.linkedin || '' }) + const [values, setValues] = useState({ + github: user.connections?.github || '', + linkedin: user.connections?.linkedin || '' + }) const [errors, setErrors] = useState({}) const [message, setMessage] = useState('') - const change = (event) => { setMessage(''); setValues((current) => ({ ...current, [event.target.name]: event.target.value })) } + const change = (event) => { + setMessage('') + setValues((current) => ({ ...current, [event.target.name]: event.target.value })) + } + const persist = async (github, linkedin, success) => { await saveConnections({ github, linkedin }) setValues({ github: github || '', linkedin: linkedin || '' }) setMessage(success) } + const submit = async (event) => { event.preventDefault() const github = normalizeGithub(values.github) @@ -91,20 +153,55 @@ function ConnectForm() { if (Object.keys(found).length) return await persist(github.value, linkedin.value, 'Your connections are saved.') } + const disconnect = (name) => { setErrors({}) - const next = { github: user.connections?.github || null, linkedin: user.connections?.linkedin || null, [name]: null } + const next = { + github: user.connections?.github || null, + linkedin: user.connections?.linkedin || null, + [name]: null + } return persist(next.github, next.linkedin, `${name === 'github' ? 'GitHub' : 'LinkedIn'} disconnected.`) } - return
- Connect -

Bring your
world in.

- - {user.connections?.github &&

✓ Connected as @{user.connections.github}

} - - {user.connections?.linkedin &&

✓ Connected: LinkedIn profile

} - {message && {message}} - - + return ( +
+ Connect +

Bring your
world in.

+ + {user.connections?.github && ( +

+ ✓ Connected as @{user.connections.github}{' '} + +

+ )} + + {user.connections?.linkedin && ( +

+ ✓ Connected: LinkedIn profile{' '} + +

+ )} + {message && {message}} + + + ) } diff --git a/frontend/src/pages/RecordingEditor.jsx b/frontend/src/pages/RecordingEditor.jsx index 766aa0f..8016d7c 100644 --- a/frontend/src/pages/RecordingEditor.jsx +++ b/frontend/src/pages/RecordingEditor.jsx @@ -12,6 +12,7 @@ import { safeRouteId } from '../utils/validators' export default function RecordingEditor() { const [params] = useSearchParams() const videoId = safeRouteId(params.get('v')) + return } @@ -23,19 +24,48 @@ function EditScreen({ videoId }) { const video = publishedVideos.find((item) => item.id === videoId) const recording = recordings[videoId] - if (!video) return Back to library}>It may have been unpublished or removed. - if (!recording) return Back to video}>You need a recording before you can trim it. - - return updateEdit(video.id, patch)} - onSubmit={async () => { - await submitRecording(video, recording) - removeRecording(video.id) - await notify({ type: 'submission', title: 'Recording submitted', body: video.title, href: watchUrl(video.id) }).catch(() => {}) - navigate(watchUrl(video.id), { state: { justSubmitted: true } }) - }} - /> + if (!video) { + return ( + Back to library} + > + It may have been unpublished or removed. + + ) + } + + if (!recording) { + return ( + Back to video} + > + You need a recording before you can trim it. + + ) + } + + return ( + updateEdit(video.id, patch)} + onSubmit={async () => { + await submitRecording(video, recording) + removeRecording(video.id) + await notify({ + type: 'submission', + title: 'Recording submitted', + body: video.title, + href: watchUrl(video.id) + }).catch(() => {}) + navigate(watchUrl(video.id), { state: { justSubmitted: true } }) + }} + /> + ) } function Editor({ video, recording, onEdit, onSubmit }) { @@ -65,63 +95,158 @@ function Editor({ video, recording, onEdit, onSubmit }) { const togglePlay = () => { const element = preview.current - if (!element.paused) { element.pause(); return } - if (element.currentTime < trimRange.start || element.currentTime >= trimRange.end - 0.05) element.currentTime = trimRange.start + if (!element.paused) { + element.pause() + return + } + if (element.currentTime < trimRange.start || element.currentTime >= trimRange.end - 0.05) { + element.currentTime = trimRange.start + } element.play().catch(() => {}) } + const onTimeUpdate = (event) => { const element = event.currentTarget setTime(element.currentTime) - if (!element.paused && element.currentTime >= trimRange.end) { element.pause(); element.currentTime = trimRange.end } + if (!element.paused && element.currentTime >= trimRange.end) { + element.pause() + element.currentTime = trimRange.end + } } + const change = (patch) => { setTrimRange((current) => ({ ...current, ...patch })) setTrimApplied(false) - if (preview.current) preview.current.currentTime = patch.start ?? patch.end ?? trimRange.start + if (preview.current) { + preview.current.currentTime = patch.start ?? patch.end ?? trimRange.start + } } + const applyTrim = () => { onEdit(trimRange) setTrimApplied(true) - if (preview.current) preview.current.currentTime = trimRange.start + if (preview.current) { + preview.current.currentTime = trimRange.start + } } + const submit = async () => { setBusy(true) setError('') - try { await onSubmit() } catch (caught) { setError(caught.message || 'Your recording could not be submitted. Try again.'); setBusy(false) } + try { + await onSubmit() + } catch (caught) { + setError(caught.message || 'Your recording could not be submitted. Try again.') + setBusy(false) + } } - return
- -
-
- {video.title} -

Make it yours.

-

Review your recording before you submit it.

-
-
- -
-
+ + + + + + {error && {error}} + + {confirming ? ( +
+

Submit this recording? Once submitted it can't be watched, edited or deleted, but you can record this video again after a short cooldown.

+
+ + +
+
+ ) : ( + + )} + + Back to player + + + + ) } diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx index 5fece6b..8a35f07 100644 --- a/frontend/src/pages/Signup.jsx +++ b/frontend/src/pages/Signup.jsx @@ -16,6 +16,7 @@ export default function Signup() { const [errors, setErrors] = useState({}) const [formError, setFormError] = useState('') const [busy, setBusy] = useState(false) + const change = (event) => setValues((current) => ({ ...current, [event.target.name]: event.target.value })) const submit = async (event) => { @@ -28,20 +29,80 @@ export default function Signup() { try { const result = await signup(values) navigate(`${paths.verifyEmail}?email=${encodeURIComponent(result.email)}`, { replace: true, state: { emailSent: result.verificationEmailSent } }) - } catch (error) { setFormError(error.message); setBusy(false) } + } catch (error) { + setFormError(error.message) + setBusy(false) + } } - return Already signed up? Verify your email · Log in}> -
-
- - -
- - - - {formError && {formError}} - - -
+ return ( + + Already signed up? Verify your email · Log in + + } + > +
+
+ + +
+ + + + {formError && {formError}} + + +
+ ) } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 54dfccd..97cc11e 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -66,6 +66,44 @@ async function request(path, options = {}) { const json = (method, body) => ({ method, body: JSON.stringify(body) }) const normalizeList = (items) => (items || []).map(normalize) +const PUBLIC_STATS_CACHE_KEY = `${PREFIX}public-stats` +const PUBLIC_STATS_CACHE_TTL_MS = 30 * 60 * 1000 +let publicStatsRequest + +function isPublicStats(value) { + return value + && Number.isInteger(value.contributors) && value.contributors >= 0 + && Number.isInteger(value.publishedVideos) && value.publishedVideos >= 0 + && Number.isInteger(value.categories) && value.categories >= 0 +} + +function readCachedPublicStats() { + try { + const raw = window.localStorage.getItem(PUBLIC_STATS_CACHE_KEY) + if (!raw) return null + + const cached = JSON.parse(raw) + if (!isPublicStats(cached?.value) || !Number.isFinite(cached?.savedAt)) { + window.localStorage.removeItem(PUBLIC_STATS_CACHE_KEY) + return null + } + if (Date.now() - cached.savedAt >= PUBLIC_STATS_CACHE_TTL_MS) { + window.localStorage.removeItem(PUBLIC_STATS_CACHE_KEY) + return null + } + return cached.value + } catch { + return null + } +} + +function cachePublicStats(value) { + try { + window.localStorage.setItem(PUBLIC_STATS_CACHE_KEY, JSON.stringify({ value, savedAt: Date.now() })) + } catch { + // The stats request still succeeds when browser storage is unavailable or full. + } +} const notifications = { async list(userId) { return readLocal('notifications', []).filter((item) => item.userId === userId).sort((a, b) => b.createdAt - a.createdAt) }, @@ -83,6 +121,23 @@ const notifications = { } export const api = { + public: { + stats() { + const cached = readCachedPublicStats() + if (cached) return Promise.resolve(cached) + if (!publicStatsRequest) { + publicStatsRequest = request(apiRoutes.public.stats()) + .then((stats) => { + if (!isPublicStats(stats)) throw new Error('The server returned invalid community statistics.') + cachePublicStats(stats) + return stats + }) + .finally(() => { publicStatsRequest = null }) + } + return publicStatsRequest + }, + }, + auth: { async session() { return normalize(await request(apiRoutes.auth.session())) }, async register(values) { return normalize(await request(apiRoutes.auth.signup(), json('POST', values))) }, diff --git a/frontend/src/services/apiRoutes.js b/frontend/src/services/apiRoutes.js index 5ebcaba..4730d34 100644 --- a/frontend/src/services/apiRoutes.js +++ b/frontend/src/services/apiRoutes.js @@ -9,6 +9,9 @@ const qs = (params = {}) => { export const apiRoutes = { health: () => `${API_BASE}/health`, + public: { + stats: () => `${API_BASE}/public/stats`, + }, auth: { signup: () => `${API_BASE}/auth/signup`, diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index 2f6720a..12c1b2d 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -1,18 +1,29 @@ /* Login and signup. */ @layer components { - .auth { @apply grid min-h-[calc(100vh-72px)] grid-cols-1 md:grid-cols-[42%_58%]; } + .auth { @apply grid min-h-[calc(100vh-72px)] grid-cols-1 md:grid-cols-[42%_58%]; min-height: calc(100svh - 72px); } .auth-art { - @apply relative flex min-h-[240px] items-end overflow-hidden bg-cover bg-center p-8 sm:p-12 md:min-h-0; + @apply relative hidden min-h-0 items-end overflow-hidden bg-cover bg-center p-8 sm:p-12 md:flex; background-image: url('https://images.unsplash.com/photo-1556761175-4b46a572b786?auto=format&fit=crop&w=1000&q=80'); } .auth-art::before { content: ''; @apply absolute inset-0; background: linear-gradient(160deg, rgb(0 64 26 / 0.86) 20%, rgb(0 46 18 / 0.94) 100%), radial-gradient(70% 60% at 100% 0%, rgb(64 224 208 / 0.4), transparent 70%); } .auth-art-copy { @apply relative pb-2 md:pb-10; } - .auth-panel { @apply grid place-items-center bg-page px-5 py-12 sm:py-16; } - .auth-card { @apply w-full max-w-[440px] rounded-3xl border border-line bg-surface p-7 shadow-lift sm:p-10; } - .auth-back { @apply mb-8 inline-block rounded-full text-sm font-semibold text-muted hover:text-accent-ink; } + .auth-panel { @apply grid min-w-0 place-items-center bg-page px-4 py-6 sm:px-6 sm:py-10 md:px-8 md:py-16; } + .auth-card { @apply w-full max-w-110 rounded-2xl border border-line bg-surface p-5 shadow-soft sm:rounded-3xl sm:p-8 md:p-10; } + .auth-back { @apply mb-6 inline-block rounded-full text-sm font-semibold text-muted hover:text-accent-ink sm:mb-8; } .auth-sub { @apply mt-4 text-base text-muted; } .auth-submit { @apply mt-8; } .auth-footer { @apply mt-7 text-center text-base text-muted; } .auth-footer a { @apply font-semibold text-accent-ink underline underline-offset-4 decoration-2 decoration-transparent hover:decoration-current; } .auth-dev { @apply mt-8 rounded-xl border border-dashed border-line-strong p-3 text-sm text-faint; } + + @media (max-width: 767px) { + .auth-panel { + background-image: + linear-gradient(160deg, rgb(0 64 26 / 0.78) 20%, rgb(0 46 18 / 0.86) 100%), + radial-gradient(70% 60% at 100% 0%, rgb(64 224 208 / 0.3), transparent 70%), + url('https://images.unsplash.com/photo-1556761175-4b46a572b786?auto=format&fit=crop&w=1000&q=80'); + background-size: cover; + background-position: center; + } + } } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 41cec9e..20d00e4 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -17,11 +17,10 @@ /* ---------- Brand ---------- */ .brand { @apply flex items-center gap-2.5 font-ui text-2xl font-bold tracking-tight text-heading; } - .brand-mark { @apply grid h-9 w-9 place-items-center rounded-full bg-deep font-display text-xl font-bold leading-none text-turquoise; } - :root[data-theme='dark'] .brand-mark { background: var(--color-turquoise); color: var(--color-deep); } + .brand-mark { @apply h-9 w-11 shrink-0; --brand-mark-color: var(--color-heading); } .brand-accent { @apply text-secondary-ink; } .brand-light { color: #fff; } - .brand-light .brand-mark { background: var(--color-turquoise); color: var(--color-deep); } + .brand-light .brand-mark { --brand-mark-color: #fff; } .brand-light .brand-accent { color: var(--color-turquoise); } /* ---------- Buttons ---------- */ diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css index b5d7579..d41faef 100644 --- a/frontend/src/styles/landing.css +++ b/frontend/src/styles/landing.css @@ -14,7 +14,7 @@ .hero-art { @apply relative h-[400px] sm:h-[520px]; } .hero-photo { @apply absolute inset-0 rounded-[2rem] bg-turquoise bg-cover bg-center; - background-image: url('https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=1000&q=85'); + background-image: url('/NUST_H-12.jpg'); background-blend-mode: multiply; box-shadow: 0 30px 60px rgb(0 0 0 / 0.35); } @@ -22,7 +22,17 @@ .hero-quote { @apply absolute -bottom-5 left-4 z-10 m-0 max-w-[15rem] rounded-2xl bg-white p-5 font-display text-2xl font-semibold leading-tight text-deep shadow-lift sm:-left-6; } .hero-quote small { @apply mt-2 block font-sans text-sm font-normal; color: #45604f; } /* card is always white, so its caption cannot use a theme colour */ - .marquee { @apply overflow-hidden whitespace-nowrap bg-turquoise py-4 text-center font-ui text-sm font-bold tracking-[.28em] text-deep; } + .marquee { @apply overflow-hidden border-y border-deep/10 bg-turquoise py-4 font-ui text-xs font-bold uppercase tracking-[.12em] text-deep sm:py-5 sm:text-sm sm:tracking-[.2em]; } + .marquee-track { @apply flex w-max; animation: marquee-slide 30s linear infinite; } + .marquee-content { @apply flex w-max shrink-0 items-center gap-[1.4rem] px-[.7rem]; } + .marquee-star { @apply text-deep/60; } + @keyframes marquee-slide { to { transform: translateX(-50%); } } + @media (prefers-reduced-motion: reduce) { + .marquee-track { animation: none; } + .marquee-content + .marquee-content { @apply hidden; } + .marquee-content { @apply w-full justify-evenly; } + .marquee { @apply overflow-x-auto; } + } .landing-section { @apply py-20 sm:py-28; } .landing-about { @apply grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16; } @@ -51,7 +61,7 @@ .cta-band-copy .btn { @apply mt-8; } .cta-band-art { @apply min-h-[300px] bg-turquoise bg-cover bg-center; - background-image: url('https://images.unsplash.com/photo-1543269865-cbf427effbad?auto=format&fit=crop&w=900&q=85'); + background-image: url('/MCS_Library.jpg'); background-blend-mode: multiply; } diff --git a/frontend/src/styles/player.css b/frontend/src/styles/player.css index dc98ab4..c93acf5 100644 --- a/frontend/src/styles/player.css +++ b/frontend/src/styles/player.css @@ -29,6 +29,9 @@ .recorder-video[controls] { @apply scale-x-100 object-contain; } .recorder-video.is-hidden { @apply hidden; } .recorder-empty { @apply px-4 text-center; } + .recorder-countdown { @apply absolute inset-0 z-10 grid place-content-center justify-items-center gap-2 bg-black/65 text-white; } + .recorder-countdown strong { @apply font-display text-8xl font-bold leading-none text-turquoise; } + .recorder-countdown span { @apply font-ui text-sm font-bold uppercase tracking-[.2em]; } .rec-dot { @apply mr-1 text-danger; } .recorder-delete { @apply mx-auto my-4 block rounded-full px-4 py-2 text-sm font-semibold text-danger shadow-soft transition; background-color: var(--danger-soft); } .recorder-delete:hover { background-color: color-mix(in srgb, var(--danger) 14%, var(--danger-soft)); } @@ -60,10 +63,15 @@ .editor-back { @apply mx-auto mt-5 block text-center text-sm font-semibold text-muted underline-offset-4 hover:text-accent-ink hover:underline; } /* Two overlapping range inputs form the dual-handle trim slider. Only the thumbs take pointer events. */ - .trim { @apply relative h-10; } - .trim-track { @apply absolute inset-x-0 top-1/2 h-2.5 -translate-y-1/2 rounded-full bg-line; } + .trim { @apply relative h-32; } + .trim-frame-source { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } + .trim-preview { @apply pointer-events-none absolute top-0 z-10 -translate-x-1/2 overflow-hidden rounded-xl border border-line bg-surface p-1.5 shadow-lift; } + .trim-preview-frame { @apply block h-[4.55rem] w-32 rounded-md bg-black object-cover; } + .trim-preview-frame.is-mirrored { transform: scaleX(-1); } + .trim-preview span { @apply block pt-1 text-center font-ui text-xs font-semibold tabular-nums text-heading; } + .trim-track { @apply absolute inset-x-0 bottom-4 h-2.5 rounded-full bg-line; } .trim-fill { @apply absolute h-full rounded-full; background: var(--progress); } - .trim input { @apply pointer-events-none absolute inset-0 m-0 h-10 w-full appearance-none bg-transparent; } + .trim input { @apply pointer-events-none absolute inset-x-0 bottom-0 m-0 h-10 w-full appearance-none bg-transparent; } .trim input::-webkit-slider-runnable-track { background: transparent; } .trim input::-moz-range-track { background: transparent; } .trim input::-webkit-slider-thumb { @apply pointer-events-auto h-7 w-7 cursor-ew-resize appearance-none rounded-full border-[3px] border-surface bg-brand shadow-soft; }