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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/coach/core/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
8 changes: 7 additions & 1 deletion api/coach/core/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [])]);

Expand Down
12 changes: 11 additions & 1 deletion api/test/validate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Icon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ const P = {
camera: <><path d="M3.6 8.8a2 2 0 0 1 2-2h1.9l1.3-2.1h6.4l1.3 2.1h1.9a2 2 0 0 1 2 2v8.4a2 2 0 0 1-2 2H5.6a2 2 0 0 1-2-2Z" /><circle cx="12" cy="12.8" r="3.3" /></>,
image: <><rect x="3.6" y="4.8" width="16.8" height="14.4" rx="2.6" /><circle cx="8.6" cy="9.6" r="1.7" /><path d="m4.4 17.4 4.8-4.6 3.3 3 3-2.6 4.1 4" /></>,
warning: <><path d="M12 3.4 21.2 19.4H2.8Z" /><path d="M12 9.6v4.4" /><circle cx="12" cy="16.6" r=".9" fill="currentColor" stroke="none" /></>,
instagram: <><rect x="4.5" y="4.5" width="15" height="15" rx="4.5" /><circle cx="12" cy="12" r="3.6" /><circle cx="16.5" cy="7.5" r=".7" fill="currentColor" stroke="none" /></>,
tiktok: <path d="M12.5 4.5v10.2a3.8 3.8 0 1 1-3.8-3.8c.4 0 .9.1 1.3.2V8.2a6.6 6.6 0 0 0-1.3-.1 6.6 6.6 0 1 0 6.6 6.6V9.8a6.5 6.5 0 0 0 4.2 1.5v-2.8a3.8 3.8 0 0 1-3.8-3.8h-3.2Z" />,
}

// A few keys are aliases so call sites can say what they mean.
Expand Down
122 changes: 108 additions & 14 deletions frontend/src/components/Media.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={'exmedia' + (compact ? ' compact' : '') + (mini ? ' mini' : '')} id={id} style={{ background: '#000' }}>
<iframe
src={`https://www.youtube-nocookie.com/embed/${ytId}?autoplay=1`}
title={exerciseNameFor(ex)}
style={{ width: '100%', height, border: 0, display: 'block' }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
{minimizable && (
<button className="giftoggle" onClick={toggleSize}>
<Icon name={mini ? 'expand' : 'minimize'} />{mini ? t('Expand') : t('Minimize')}
</button>
)}
</div>
)
}
return (
<div className={'exmedia' + (compact ? ' compact' : '') + (mini ? ' mini' : '')} id={id} onClick={() => setPlaying(true)} style={{ cursor: 'pointer', background: '#000' }}>
<img decoding="async" draggable={false} src={`https://img.youtube.com/vi/${ytId}/hqdefault.jpg`} alt={exerciseNameFor(ex)} style={{ objectFit: 'cover' }} />
{!mini && (
<span className="gifhint">
<Icon name="play" />{t('tap to play')}
</span>
)}
{minimizable && (
<button className="giftoggle" onClick={toggleSize}>
<Icon name={mini ? 'expand' : 'minimize'} />{mini ? t('Expand') : t('Minimize')}
</button>
)}
</div>
)
}

if (isDirectVideoUrl(ex.url)) {
return (
<div className={'exmedia' + (compact ? ' compact' : '') + (mini ? ' mini' : '')} id={id} style={{ background: '#000' }}>
<video src={ex.url} controls playsInline style={{ width: '100%', height, objectFit: 'contain', display: 'block' }} />
{minimizable && (
<button className="giftoggle" onClick={toggleSize}>
<Icon name={mini ? 'expand' : 'minimize'} />{mini ? t('Expand') : t('Minimize')}
</button>
)}
</div>
)
}

const isIg = isInstagramUrl(ex.url)
const isTt = isTikTokUrl(ex.url)
let domain = ''
try { domain = new URL(ex.url).hostname.replace(/^www\./, '') } catch {}

const title = isIg ? t('Watch on Instagram') : isTt ? t('Watch on TikTok') : t('Watch video / guide')
const iconName = isIg ? 'instagram' : isTt ? 'tiktok' : 'link'

return (
<div
className={'exlink-card' + (compact ? ' compact' : '') + (mini ? ' mini' : '')}
id={id}
role="button"
tabIndex={0}
data-swipe-ignore
onClick={() => window.open(ex.url, '_blank', 'noopener')}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
window.open(ex.url, '_blank', 'noopener')
}
}}
>
<div className={'exlink-icon' + (isIg ? ' ig' : isTt ? ' tt' : '')}>
<Icon name={iconName} />
</div>
<div className="exlink-meta">
<span className="exlink-title">{title}</span>
{domain && <span className="exlink-sub">{domain}</span>}
</div>
<Icon name="chevronRight" className="exlink-arr" />
</div>
)
}

// Built-in catalog exercise animation
const showGif = playing && failed == null
const onError = () => setFailed(showGif ? 'gif' : 'all')
const onTap = () => {
Expand All @@ -51,6 +138,13 @@ export default function Media({ ex, id, compact, minimizable }) {
}

export function Thumb({ ex }) {
if (!ex.img) return <div className="thumb thumb-x"><Icon name="dumbbell" /></div>
return <img className="thumb" loading="lazy" decoding="async" draggable={false} src={imgSrc(ex)} alt="" />
const ytId = !ex?.img && ex?.url ? getYouTubeId(ex.url) : null
if (ytId) return <img className="thumb" loading="lazy" decoding="async" draggable={false} src={`https://img.youtube.com/vi/${ytId}/hqdefault.jpg`} alt="" />
if (ex?.img) return <img className="thumb" loading="lazy" decoding="async" draggable={false} src={imgSrc(ex)} alt="" />
if (ex?.url && isInstagramUrl(ex.url)) return <div className="thumb thumb-x"><Icon name="instagram" /></div>
if (ex?.url && isTikTokUrl(ex.url)) return <div className="thumb thumb-x"><Icon name="tiktok" /></div>
if (ex?.url) return <div className="thumb thumb-x"><Icon name="play" /></div>
return <div className="thumb thumb-x"><Icon name="dumbbell" /></div>
}


67 changes: 67 additions & 0 deletions frontend/src/components/Media.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,70 @@ describe('Media gifSize', () => {
expect(host.querySelector('.exmedia.mini')).toBeFalsy()
})
})

describe('Media with video URL on custom exercises', () => {
it('renders nothing when custom exercise has no gif and no url', () => {
act(() => root.render(<Media ex={{ id: 'c1', n: 'Custom' }} />))
expect(host.querySelector('.exmedia')).toBeFalsy()
})

it('renders YouTube thumbnail and toggles to iframe on tap', () => {
const customYt = { id: 'c1', n: 'Dasds', url: 'https://youtu.be/RJvcR7AAU6o' }
act(() => root.render(<Media ex={customYt} />))
const img = host.querySelector('.exmedia img')
expect(img).toBeTruthy()
expect(img.src).toContain('RJvcR7AAU6o')
expect(host.querySelector('.gifhint')).toBeTruthy()

// tap to play
act(() => { host.querySelector('.exmedia').click() })
expect(host.querySelector('iframe')).toBeTruthy()
expect(host.querySelector('iframe').src).toContain('RJvcR7AAU6o')
})

it('renders video element for direct video URLs', () => {
const customVid = { id: 'c2', n: 'Form check', url: 'https://example.com/squat.mp4' }
act(() => root.render(<Media ex={customVid} />))
expect(host.querySelector('video')).toBeTruthy()
expect(host.querySelector('video').src).toBe('https://example.com/squat.mp4')
})

it('renders clickable link tile for general web URLs', () => {
const customLink = { id: 'c3', n: 'Guide', url: 'https://example.com/guide' }
act(() => root.render(<Media ex={customLink} />))
expect(host.querySelector('.exlink-card')).toBeTruthy()
expect(host.textContent).toContain('Watch video / guide')
expect(host.textContent).toContain('example.com')
})

it('renders Instagram URL as external-link card with platform branding', () => {
const customIg = { id: 'c4', n: 'IG Reel', url: 'https://www.instagram.com/reel/C_abc123/' }
act(() => root.render(<Media ex={customIg} />))
expect(host.querySelector('.exlink-card')).toBeTruthy()
expect(host.querySelector('.exlink-icon.ig')).toBeTruthy()
expect(host.textContent).toContain('Watch on Instagram')
expect(host.textContent).toContain('instagram.com')
expect(host.querySelector('iframe')).toBeFalsy()
})

it('renders TikTok URL as external-link card with platform branding', () => {
const customTt = { id: 'c5', n: 'TikTok', url: 'https://www.tiktok.com/@gym/video/71234567890' }
act(() => root.render(<Media ex={customTt} />))
expect(host.querySelector('.exlink-card')).toBeTruthy()
expect(host.querySelector('.exlink-icon.tt')).toBeTruthy()
expect(host.textContent).toContain('Watch on TikTok')
expect(host.textContent).toContain('tiktok.com')
expect(host.querySelector('iframe')).toBeFalsy()
})

it('renders Vimeo URL as external-link card (no inline embed)', () => {
const customVimeo = { id: 'c6', n: 'Vimeo', url: 'https://vimeo.com/76979871' }
act(() => root.render(<Media ex={customVimeo} />))
expect(host.querySelector('.exlink-card')).toBeTruthy()
expect(host.textContent).toContain('Watch video / guide')
expect(host.textContent).toContain('vimeo.com')
expect(host.querySelector('iframe')).toBeFalsy()
})
})


37 changes: 37 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,43 @@ body.chips-dragging *{cursor:inherit}
.exmedia.compact .exmedia-x{height:120px}
.exmedia.mini .exmedia-x{height:84px}

/* external video/guide link card: compact, non-intrusive action tile */
.exlink-card{
display:flex;align-items:center;gap:12px;
background:var(--surface);border-radius:var(--r-card);
padding:10px 14px;margin-bottom:12px;
cursor:pointer;color:var(--label);
transition:background var(--fast),transform var(--fast);
user-select:none;-webkit-user-select:none;
}
.exlink-card:active{background:var(--surface-2);transform:scale(.98)}
.exlink-icon{
width:34px;height:34px;border-radius:9px;
display:flex;align-items:center;justify-content:center;
background:var(--surface-2);color:var(--label);font-size:18px;flex:none;
}
.exlink-icon.ig{
background:radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
color:#fff;
}
.exlink-icon.tt{
background:#000;color:#fff;box-shadow:0 0 0 .5px var(--sep);
}
.exlink-meta{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.exlink-title{
font-size:15px;font-weight:500;color:var(--label);line-height:1.25;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.exlink-sub{
font-size:12px;color:var(--label-2);line-height:1.2;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.exlink-arr{color:var(--label-3);font-size:14px;flex:none}
.exlink-card.mini{padding:6px 12px;gap:10px}
.exlink-card.mini .exlink-icon{width:28px;height:28px;font-size:15px;border-radius:7px}
.exlink-card.mini .exlink-title{font-size:13px}
.exlink-card.mini .exlink-sub{display:none}

.sethead{
display:flex;align-items:center;gap:8px;padding:0 0 6px;
font-size:11px;color:var(--label-3);font-weight:500;text-transform:uppercase;letter-spacing:.045em;
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/lib/exercises.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,42 @@ export function matchExercise(e, query) {
const corpus = corpusOf(e)
return tokens.every(tok => corpus.includes(tok))
}

// ponytail: accepts http/https URLs only, prepending https:// if protocol is omitted.
// Rejects dangerous schemes (javascript:, data:, vbscript:) at the trust boundary.
export function cleanUrl(raw) {
if (!raw || typeof raw !== 'string') return null
let s = raw.trim()
if (!s) return null
if (!/^https?:\/\//i.test(s)) {
if (/^[a-z0-9+.-]+:/i.test(s)) return null
s = 'https://' + s
}
try {
const u = new URL(s)
if (u.protocol === 'http:' || u.protocol === 'https:') return u.href
} catch {}
return null
}

// Extract 11-character YouTube video ID from watch, short, embed, or youtu.be links.
export function getYouTubeId(url) {
if (!url || typeof url !== 'string') return null
const m = url.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|shorts\/))([\w-]{11})/)
return m ? m[1] : null
}

export function isInstagramUrl(url) {
if (!url || typeof url !== 'string') return false
return /(?:instagram\.com|instagr\.am)\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/i.test(url)
}

export function isTikTokUrl(url) {
if (!url || typeof url !== 'string') return false
return /(?:tiktok\.com\/.*(?:video|v)\/(\d+)|vm\.tiktok\.com\/[A-Za-z0-9]+)/i.test(url)
}

export function isDirectVideoUrl(url) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg|mov)(\?.*)?$/i.test(url)
}
28 changes: 27 additions & 1 deletion frontend/src/lib/exercises.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { matchExercise, normalizeStr } from './exercises.js'
import { matchExercise, normalizeStr, cleanUrl } from './exercises.js'
import { _setLangState } from './i18n-core.js'

describe('normalizeStr', () => {
Expand Down Expand Up @@ -119,3 +119,29 @@ describe('matchExercise', () => {
expect(matchExercise(benchPress, 'bench')).toBe(true)
})
})

describe('cleanUrl', () => {
it('accepts valid https and http URLs', () => {
expect(cleanUrl('https://www.youtube.com/watch?v=123')).toBe('https://www.youtube.com/watch?v=123')
expect(cleanUrl('http://example.com/guide')).toBe('http://example.com/guide')
})

it('prepends https:// if protocol is omitted', () => {
expect(cleanUrl('youtube.com/watch?v=123')).toBe('https://youtube.com/watch?v=123')
expect(cleanUrl('www.vimeo.com/456')).toBe('https://www.vimeo.com/456')
})

it('rejects malicious or dangerous protocols at trust boundary', () => {
expect(cleanUrl('javascript:alert(1)')).toBeNull()
expect(cleanUrl('data:text/html,<h1>hi</h1>')).toBeNull()
expect(cleanUrl('vbscript:msgbox(1)')).toBeNull()
})

it('rejects empty or invalid strings', () => {
expect(cleanUrl('')).toBeNull()
expect(cleanUrl(' ')).toBeNull()
expect(cleanUrl(null)).toBeNull()
expect(cleanUrl(undefined)).toBeNull()
})
})

Loading