diff --git a/api/coach/core/schemas.js b/api/coach/core/schemas.js index 163b4570b8..4f5f3078da 100644 --- a/api/coach/core/schemas.js +++ b/api/coach/core/schemas.js @@ -92,7 +92,7 @@ export const CREATE_SCHEMA = { }, customEx: { type: 'array', - items: { type: 'object', properties: { id: STR, n: STR, bp: STR, desc: STR }, required: ['id', 'n'] } + items: { type: 'object', properties: { id: STR, n: STR, bp: STR, desc: STR, url: STR }, required: ['id', 'n'] } } }, required: ['coach_contract', 'week', 'routines'] diff --git a/api/coach/core/validate.js b/api/coach/core/validate.js index 17c5b99648..cf1be6b3f4 100644 --- a/api/coach/core/validate.js +++ b/api/coach/core/validate.js @@ -79,7 +79,13 @@ export function validatePlan(data, ctx = {}) { const customEx = (Array.isArray(data.customEx) ? data.customEx : []) .filter(c => c && safeId(c.id) && !libraryHas(c.id) && isStr(c.n)) .slice(0, 20) - .map(c => ({ id: clampStr(c.id, 40), n: clampStr(c.n, 60), bp: clampStr(c.bp || 'waist', 30), ...(c.desc ? { desc: clampStr(c.desc, 400) } : {}) })); + .map(c => ({ + id: clampStr(c.id, 40), + n: clampStr(c.n, 60), + bp: clampStr(c.bp || 'waist', 30), + ...(c.desc ? { desc: clampStr(c.desc, 400) } : {}), + ...(isStr(c.url) && /^https?:\/\//i.test(c.url.trim()) ? { url: clampStr(c.url.trim(), 500) } : {}) + })); // Ids the plan may name: invented in this same answer, or already the user's own. const proposedIds = new Set([...customEx.map(c => c.id), ...(ctx.customIds || [])]); diff --git a/api/test/validate.test.js b/api/test/validate.test.js index 1776dca9da..acc251297e 100644 --- a/api/test/validate.test.js +++ b/api/test/validate.test.js @@ -49,10 +49,20 @@ test('a plan referencing an unknown exercise is rejected, not quietly trimmed', test('a plan may reference a custom exercise it defines in the same answer', () => { const r = validatePlan({ routines: [{ name: 'A', ex: [{ id: 'cx1', sets: 3, reps: 10 }] }], - customEx: [{ id: 'cx1', n: 'Sandbag carry', bp: 'back' }] + customEx: [{ id: 'cx1', n: 'Sandbag carry', bp: 'back', url: 'https://youtube.com/watch?v=123' }] }); assert.equal(r.ok, true); assert.equal(r.bundle.customEx[0].n, 'Sandbag carry'); + assert.equal(r.bundle.customEx[0].url, 'https://youtube.com/watch?v=123'); +}); + +test('custom exercise url must be http/https, javascript url is stripped', () => { + const r = validatePlan({ + routines: [{ name: 'A', ex: [{ id: 'cx1', sets: 3, reps: 10 }] }], + customEx: [{ id: 'cx1', n: 'Sandbag carry', bp: 'back', url: 'javascript:alert(1)' }] + }); + assert.equal(r.ok, true); + assert.equal(r.bundle.customEx[0].url, undefined); }); test('the week may only point at routines the plan actually defines', () => { diff --git a/frontend/src/components/Icon.jsx b/frontend/src/components/Icon.jsx index 065eb24298..2a189dc6cd 100644 --- a/frontend/src/components/Icon.jsx +++ b/frontend/src/components/Icon.jsx @@ -119,6 +119,8 @@ const P = { camera: <>, image: <>, warning: <>, + instagram: <>, + tiktok: , } // A few keys are aliases so call sites can say what they mean. diff --git a/frontend/src/components/Media.jsx b/frontend/src/components/Media.jsx index 1c90fcca88..93510930e8 100644 --- a/frontend/src/components/Media.jsx +++ b/frontend/src/components/Media.jsx @@ -1,30 +1,117 @@ import { useState } from 'react' -import { imgSrc, gifSrc } from '../lib/exercises.js' +import { imgSrc, gifSrc, getYouTubeId, isInstagramUrl, isTikTokUrl, isDirectVideoUrl } from '../lib/exercises.js' import { useStore } from '../store/useStore.js' import { t, exerciseNameFor } from '../lib/i18n.js' import Icon from './Icon.jsx' -// Big autoplaying animation; tap toggles to the still frame. `compact` shrinks it (superset cards). -// Custom exercises have no media — the animation stays blank by design (issue #11). +// Big autoplaying animation or attached video; tap toggles to still frame or plays video. +// `compact` shrinks it (superset cards). // `minimizable` (workout view) adds a persistent minimize/expand control so the animation stops // eating the screen; the chosen size is saved to settings and carries across exercises and // future workouts (issue #12). Settings can also turn workout media off entirely -// (gifSize 'off') — then nothing renders here and the exercise card closes up, exactly like -// a custom exercise without media. Any other/legacy value behaves as 'full'. +// (gifSize 'off') — then nothing renders here and the exercise card closes up. export default function Media({ ex, id, compact, minimizable }) { - const [playing, setPlaying] = useState(true) - // 'gif' → the animation failed, the still is showing; 'all' → the still failed too. Media is - // fetched from wherever the build points (a mount, a CDN): a dropped connection, an expired - // session on a gated instance or a CDN hiccup used to leave the browser's broken-image glyph - // on a white block. Now the still stands in for the animation, a neutral tile stands in for - // both, and a tap tries again — no text, so nothing new to translate. + const isGif = !!ex?.gif + const [playing, setPlaying] = useState(isGif) const [failed, setFailed] = useState(null) const gifSize = useStore(s => s.S.gifSize) const update = useStore(s => s.update) - if (!ex.gif) return null + + if (!ex || (!ex.gif && !ex.url)) return null if (minimizable && gifSize === 'off') return null + const mini = minimizable && gifSize === 'mini' + const height = compact ? 120 : mini ? 84 : 320 const toggleSize = e => { e.stopPropagation(); update(s => { s.gifSize = mini ? 'full' : 'mini' }) } + + // Custom exercise video URL + if (!ex.gif && ex.url) { + const ytId = getYouTubeId(ex.url) + if (ytId) { + if (playing) { + return ( +
+