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 (
+
+
+ {minimizable && (
+
+ )}
+
+ )
+ }
+ return (
+ setPlaying(true)} style={{ cursor: 'pointer', background: '#000' }}>
+

+ {!mini && (
+
+ {t('tap to play')}
+
+ )}
+ {minimizable && (
+
+ )}
+
+ )
+ }
+
+ if (isDirectVideoUrl(ex.url)) {
+ return (
+
+
+ {minimizable && (
+
+ )}
+
+ )
+ }
+
+ 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 (
+ window.open(ex.url, '_blank', 'noopener')}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ window.open(ex.url, '_blank', 'noopener')
+ }
+ }}
+ >
+
+
+
+
+ {title}
+ {domain && {domain}}
+
+
+
+ )
+ }
+
+ // Built-in catalog exercise animation
const showGif = playing && failed == null
const onError = () => setFailed(showGif ? 'gif' : 'all')
const onTap = () => {
@@ -51,6 +138,13 @@ export default function Media({ ex, id, compact, minimizable }) {
}
export function Thumb({ ex }) {
- if (!ex.img) return
- return
+ const ytId = !ex?.img && ex?.url ? getYouTubeId(ex.url) : null
+ if (ytId) return
+ if (ex?.img) return
+ if (ex?.url && isInstagramUrl(ex.url)) return
+ if (ex?.url && isTikTokUrl(ex.url)) return
+ if (ex?.url) return
+ return
}
+
+
diff --git a/frontend/src/components/Media.test.jsx b/frontend/src/components/Media.test.jsx
index 40e5b40992..b543ad886c 100644
--- a/frontend/src/components/Media.test.jsx
+++ b/frontend/src/components/Media.test.jsx
@@ -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())
+ 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())
+ 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())
+ 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())
+ 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())
+ 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())
+ 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())
+ expect(host.querySelector('.exlink-card')).toBeTruthy()
+ expect(host.textContent).toContain('Watch video / guide')
+ expect(host.textContent).toContain('vimeo.com')
+ expect(host.querySelector('iframe')).toBeFalsy()
+ })
+})
+
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 12a1643b4f..cf19bd3aa0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
diff --git a/frontend/src/lib/exercises.js b/frontend/src/lib/exercises.js
index 81173a5823..7032ea40c1 100644
--- a/frontend/src/lib/exercises.js
+++ b/frontend/src/lib/exercises.js
@@ -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)
+}
diff --git a/frontend/src/lib/exercises.test.js b/frontend/src/lib/exercises.test.js
index 1bc689753a..24fbf78c2d 100644
--- a/frontend/src/lib/exercises.test.js
+++ b/frontend/src/lib/exercises.test.js
@@ -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', () => {
@@ -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,hi
')).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()
+ })
+})
+
diff --git a/frontend/src/lib/plan-share.js b/frontend/src/lib/plan-share.js
index 30ef09a8ae..d5d16abee6 100644
--- a/frontend/src/lib/plan-share.js
+++ b/frontend/src/lib/plan-share.js
@@ -8,7 +8,7 @@
// 2. A clean, printable page (Save as PDF) where a single exercise never splits across
// a page break — each exercise, and each routine that fits, stays in one place.
-import { EXIDX, isBodyweightEq } from './exercises.js'
+import { EXIDX, isBodyweightEq, cleanUrl } from './exercises.js'
import { modeOf, fmtSec, isBw, isPerSide, sideReps, MAX_PLANNED_WARMUPS } from './history.js'
import { deriveSessionName } from './session-merge.js'
import { uid, todayISO, DAYN, weekOrder, weekStartOf, fmtNum, exCount } from './format.js'
@@ -104,7 +104,7 @@ export function buildPlanBundle(S, name) {
const usedIds = new Set(routines.flatMap(r => r.ex.map(e => e.id)))
const customEx = (S.customEx || [])
.filter(c => usedIds.has(c.id))
- .map(c => ({ id: c.id, n: c.n, bp: c.bp, ...(c.desc ? { desc: c.desc } : {}) }))
+ .map(c => ({ id: c.id, n: c.n, bp: c.bp, ...(c.desc ? { desc: c.desc } : {}), ...(c.url ? { url: c.url } : {}) }))
// A weekday can hold several routines (merge order preserved). `[].concat` normalises a
// legacy scalar id to a one-element list, so a bundle written before this change and one
// written after are read the same way at the other end.
@@ -127,7 +127,11 @@ export function parsePlan(raw) {
if (!data || !data.opengym_plan || !Array.isArray(data.routines)) {
throw new Error(t('this isn’t an openGym plan file'))
}
- const customEx = (Array.isArray(data.customEx) ? data.customEx : []).filter(c => c && c.id)
+ const customEx = (Array.isArray(data.customEx) ? data.customEx : []).filter(c => c && c.id).map(c => {
+ const u = cleanUrl(c.url)
+ const { url: _orig, ...rest } = c
+ return { ...rest, ...(u ? { url: u } : {}) }
+ })
const known = new Set(customEx.map(c => c.id))
let dropped = 0
const routines = data.routines.filter(r => r && Array.isArray(r.ex)).map(r => ({
@@ -171,10 +175,18 @@ export function mergePlan(s, bundle, { schedule } = {}) {
const exIdMap = {}
;(bundle.customEx || []).forEach(c => {
const same = s.customEx.find(x => (x.n || '').toLowerCase() === (c.n || '').toLowerCase() && x.bp === c.bp)
- if (same) { exIdMap[c.id] = same.id; return }
+ if (same) {
+ if (!same.url && c.url) {
+ const u = cleanUrl(c.url)
+ if (u) same.url = u
+ }
+ exIdMap[c.id] = same.id
+ return
+ }
const nid = uid()
exIdMap[c.id] = nid
- s.customEx.push({ id: nid, n: c.n, bp: c.bp, ...(c.desc ? { desc: c.desc } : {}) })
+ const u = cleanUrl(c.url)
+ s.customEx.push({ id: nid, n: c.n, bp: c.bp, custom: true, ...(c.desc ? { desc: c.desc } : {}), ...(u ? { url: u } : {}) })
})
const ridMap = {}
bundle.routines.forEach(r => {
diff --git a/frontend/src/lib/plan-share.test.js b/frontend/src/lib/plan-share.test.js
index 737f24bb7a..ac293f942e 100644
--- a/frontend/src/lib/plan-share.test.js
+++ b/frontend/src/lib/plan-share.test.js
@@ -143,4 +143,33 @@ describe('week schedule as a routine-id list', () => {
it('scheduledDays counts a populated array day as 1 and a [] / absent day as 0', () => {
expect(parsePlan({ opengym_plan: 1, routines: [], customEx: [], week: { 1: ['a'], 2: [], 4: 'b' } }).scheduledDays).toBe(2)
})
+
+ it('carries custom exercise url through bundle export, parse, and merge', () => {
+ const custom = { id: 'c1', n: 'Trap Bar Deadlift', bp: 'back', desc: 'Hip hinge', url: 'https://youtube.com/watch?v=trapbar' }
+ const source = {
+ routines: [{ id: 'r1', name: 'Pull', ex: [{ id: 'c1', sets: 3, reps: 5 }] }],
+ week: { 1: ['r1'] },
+ customEx: [custom]
+ }
+ const bundle = buildPlanBundle(source, 'Trap Bar Plan')
+ expect(bundle.customEx[0].url).toBe('https://youtube.com/watch?v=trapbar')
+
+ const parsed = parsePlan(bundle)
+ expect(parsed.customEx[0].url).toBe('https://youtube.com/watch?v=trapbar')
+
+ const target = { routines: [], week: {}, customEx: [] }
+ mergePlan(target, parsed)
+ expect(target.customEx[0].url).toBe('https://youtube.com/watch?v=trapbar')
+ expect(target.customEx[0].custom).toBe(true)
+
+ // Merging into a target that already has the same exercise without a url backfills it
+ const targetWithExisting = {
+ routines: [],
+ week: {},
+ customEx: [{ id: 'existing1', n: 'Trap Bar Deadlift', bp: 'back', custom: true }]
+ }
+ mergePlan(targetWithExisting, parsed)
+ expect(targetWithExisting.customEx[0].url).toBe('https://youtube.com/watch?v=trapbar')
+ })
})
+
diff --git a/frontend/src/sheets.jsx b/frontend/src/sheets.jsx
index 4c04e4720b..5990cb0765 100644
--- a/frontend/src/sheets.jsx
+++ b/frontend/src/sheets.jsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useStore } from './store/useStore.js'
import { useUI } from './store/useUI.js'
-import { EXDB, EXIDX, BODYPARTS, isCardio, isBodyweightEq, allExercises, equipmentOf, smOf, matchExercise, exOr } from './lib/exercises.js'
+import { EXDB, EXIDX, BODYPARTS, isCardio, isBodyweightEq, allExercises, equipmentOf, smOf, matchExercise, exOr, cleanUrl, getYouTubeId, isDirectVideoUrl } from './lib/exercises.js'
import { activeProfile, exAvailable, ALL_EQUIPMENT, newProfile } from './lib/equipment.js'
import { fmtDate, fmtNum, capWords, fmtVol, fmtDur, durPart, todayISO, isoOf, uid, exCount, DAYN, DAYS, weekOrder, weekStartOf, weekDayOffset, MONTHS_LONG, ACCENTS } from './lib/format.js'
import { lastEntryFor, bestWeightFor, bestWeightForEntry, buildSets, effectiveRoutineIds, workoutVolume, setsDone, setsDoneActive, setUnitsTotal, lastBW, supersetUnits, unitOf, setLabel, defaultConfig, cleanupSg, modeOf, effortOf, EFFORT, capEffort, stepEffort, isBw, isPerSide, sideReps, workSetsDone, applyIntensifierPlan, MAX_PLANNED_WARMUPS, NOTE_MAX } from './lib/history.js'
@@ -659,6 +659,7 @@ function ExerciseDetail({ ex, close }) {
{(ex.secondaries?.length ? ex.secondaries : smOf(ex)).slice(0, 3).map((s, i) => {t(MUSCLE_NAME[s] || s)})}
{ex.desc && {ex.desc}
}
+ {ex.url && (getYouTubeId(ex.url) || isDirectVideoUrl(ex.url)) && }
{best > 0 && {t('Best:')} {fmtNum(best)} {st.unit}{last ? ` · ${t('last')} ${fmtDate(last.d)}: ${last.sets.map(s => setLabel(ex.id, s, last.target)).join(', ')}` : ''}
}
{last && }
@@ -769,6 +770,7 @@ function CustomExForm({ existing, prefill, onDone, close }) {
const [bp, setBp] = useState(existing ? existing.bp : '')
const [eq, setEq] = useState(existing ? (existing.eq || '') : '')
const [desc, setDesc] = useState(existing ? (existing.desc || '') : '')
+ const [url, setUrl] = useState(existing ? (existing.url || '') : '')
const [primaries, setPrimaries] = useState(() => {
if (existing && Array.isArray(existing.primaries) && existing.primaries.length) return [...existing.primaries]
if (existing?.bp === 'cardio') return ['cardiovascular system']
@@ -790,16 +792,18 @@ function CustomExForm({ existing, prefill, onDone, close }) {
const dup = allExercises(S()).find(e => e.n.toLowerCase() === name.toLowerCase() && e.id !== (existing || {}).id)
if (dup) { toast(t('“{0}” already exists', dup.n)); return }
const d = desc.trim().slice(0, 1000)
+ const u = cleanUrl(url)
const prim = bp === 'cardio' ? ['cardiovascular system'] : [...primaries]
const sm = secondaries.filter(m => !prim.includes(m))
const groups = [...prim, ...sm]
let id = existing && existing.id
if (existing) update(s => { const c = (s.customEx || []).find(x => x.id === id); if (c) {
c.n = name; c.bp = bp; c.desc = d; c.tg = prim[0] || ''; c.sm = sm; c.muscleGroups = groups; c.primaries = prim; c.secondaries = sm; c.eq = eq
+ if (u) c.url = u; else delete c.url
} })
else {
id = 'c' + uid()
- update(s => { (s.customEx = s.customEx || []).push({ id, n: name, bp, desc: d, tg: prim[0] || '', sm, muscleGroups: groups, primaries: prim, secondaries: sm, eq, custom: true }) })
+ update(s => { (s.customEx = s.customEx || []).push({ id, n: name, bp, desc: d, tg: prim[0] || '', sm, muscleGroups: groups, primaries: prim, secondaries: sm, eq, custom: true, ...(u ? { url: u } : {}) }) })
}
close()
toast(existing ? t('Saved') : t('“{0}” created', name))
@@ -829,6 +833,8 @@ function CustomExForm({ existing, prefill, onDone, close }) {
{bp === 'cardio' && {t('Cardio exercises log time + speed instead of weight × reps.')}
}