diff --git a/prototypes/slide-embed-picker/PRD.md b/prototypes/slide-embed-picker/PRD.md new file mode 100644 index 00000000..0f660d0f --- /dev/null +++ b/prototypes/slide-embed-picker/PRD.md @@ -0,0 +1,115 @@ +# PRD — Slide Embed Picker + +**Feature area:** Course → Lesson → Materials → Slide tab +**Status:** Draft for review +**Author:** Design exploration (cloud agent) +**Related prototype:** [`index.html`](./index.html) — three interaction directions behind the prototype picker + +--- + +## 1. Problem + +The Slide tab is a single unlabeled URL input with one line of helper text: _"You can embed Google Slides or Canva Presentation."_ (`apps/dashboard/src/lib/features/course/components/lesson/slide/slide.svelte`). + +This creates three problems for the teacher building a lesson: + +1. **It isn't clear _what_ to paste.** A raw edit URL (`.../edit#slide=id.p`) does not embed. Google Slides needs a _published_ `/embed` link; Canva needs `?embed`; PowerPoint, Pitch, Gamma and SlideShare hand you a full `', + example: '', + steps: [ + 'Save the deck to OneDrive, then open it in PowerPoint for the web.', + 'Choose File → Share → Embed and pick your dimensions.', + 'Copy the embed code and paste the whole <iframe> here.' + ], + test: (v) => /onedrive\.live\.com|officeapps\.live\.com|1drv\.ms|sharepoint\.com/i.test(v), + resolve(v) { + const src = iframeSrc(v) || v.trim(); + if (!/onedrive\.live\.com|officeapps\.live\.com|1drv\.ms|sharepoint\.com/i.test(src)) + return err('Paste the full embed code from PowerPoint for the web.'); + return ok(src); + } + }, + { + id: 'keynote', name: 'Keynote', kind: 'link', + hint: 'Collaborate → Copy Link (Anyone / View only)', + placeholder: 'https://www.icloud.com/keynote/0abc...#My_Deck', + example: 'https://www.icloud.com/keynote/0demoKeynoteShareId#Quarterly_Review', + steps: [ + 'Click Collaborate and set access to Anyone with the link · View only.', + 'Click Copy Link, then Share (keep collaboration on).', + 'Paste the iCloud link here.' + ], + test: (v) => /icloud\.com\/keynote/i.test(v), + resolve(v) { + let u = iframeSrc(v) || v.trim(); + if (!/icloud\.com\/keynote/i.test(u)) return err('That doesn\u2019t look like an iCloud Keynote link.'); + u = u.split('#')[0].replace(/\?embed=true/i, ''); + u += (u.includes('?') ? '&' : '?') + 'embed=true'; + return ok(u); + } + }, + { + id: 'figma', name: 'Figma Slides', kind: 'link', + hint: 'Copy the figma.com/slides or /deck link', + placeholder: 'https://www.figma.com/slides/AbCd.../Deck-Name', + example: 'https://www.figma.com/slides/DemoFileKey123/Product-Review', + steps: [ + 'Open your Figma Slides file and check link sharing is enabled.', + 'Copy the file URL from the address bar (/slides/ or /deck/).', + 'Paste it here \u2014 we build the Embed Kit URL for you.' + ], + test: (v) => /figma\.com\/(slides|deck)/i.test(v), + resolve(v) { + let u = iframeSrc(v) || v.trim(); + if (!/figma\.com\/(slides|deck)/i.test(u)) return err('That doesn\u2019t look like a Figma Slides link.'); + u = u.replace(/https?:\/\/(www\.)?figma\.com/i, 'https://embed.figma.com'); + u = u.split('#')[0].replace(/\?.*$/, ''); + u += '?embed-host=classroomio'; + return ok(u); + } + }, + { + id: 'prezi', name: 'Prezi', kind: 'link', + hint: 'Share → Embed, or copy the view link', + placeholder: 'https://prezi.com/view/AbCd12345/', + example: 'https://prezi.com/view/demoPreziViewId/', + steps: [ + 'From the dashboard, open Share on your presentation.', + 'Click Embed and copy the code (or copy the view link).', + 'Paste it here \u2014 we append /embed automatically.' + ], + test: (v) => /prezi\.com\/(view|p)\//i.test(v) || /prezi\.com\/embed/i.test(v), + resolve(v) { + let u = iframeSrc(v) || v.trim(); + if (/prezi\.com\/embed/i.test(u)) return ok(u); + if (!/prezi\.com\/(view|p)\//i.test(u)) return err('That doesn\u2019t look like a Prezi link.'); + u = u.replace(/\/+$/, '').split('#')[0].split('?')[0]; + return ok(u + '/embed'); + } + }, + { + id: 'pitch', name: 'Pitch', kind: 'code', + hint: 'Share externally → link → Copy embed code', + placeholder: '', + example: '', + steps: [ + 'Click Share → Share externally and create a link (enable public access).', + 'Open the link\u2019s Options (…) and choose Copy embed code.', + 'Paste the whole embed code here.' + ], + test: (v) => /pitch\.com\/(embed|public)/i.test(v), + resolve(v) { + let u = iframeSrc(v) || v.trim(); + if (!/pitch\.com/i.test(u)) return err('Paste the embed code from Pitch (Share externally).'); + u = u.replace(/pitch\.com\/public\//i, 'pitch.com/embed/'); + return ok(u); + } + }, + { + id: 'gamma', name: 'Gamma', kind: 'code', + hint: 'Share → enable public access → copy iframe', + placeholder: '', + example: '', + steps: [ + 'Open Share and toggle Allow public access.', + 'Copy the generated iframe embed code.', + 'Paste the whole embed code here.' + ], + test: (v) => /gamma\.app\/(embed|docs)/i.test(v), + resolve(v) { + let u = iframeSrc(v) || v.trim(); + if (!/gamma\.app/i.test(u)) return err('Paste the embed code from Gamma\u2019s Share menu.'); + u = u.replace(/gamma\.app\/docs\//i, 'gamma.app/embed/'); + return ok(u); + } + }, + { + id: 'slideshare', name: 'SlideShare', kind: 'code', + hint: 'Below the deck → Share → Embed', + placeholder: '', + example: '', + steps: [ + 'Open the public deck on slideshare.net.', + 'Click Share under the viewer and open the Embed tab.', + 'Copy the embed code and paste the whole <iframe> here.' + ], + test: (v) => /slideshare\.net/i.test(v), + resolve(v) { + const u = iframeSrc(v) || v.trim(); + if (!/slideshare\.net\/(slideshow\/)?embed_code/i.test(u)) + return err('Paste the SlideShare embed code (it contains /embed_code/).'); + return ok(u); + } + }, + { + id: 'beautiful', name: 'Beautiful.ai', kind: 'link', + hint: 'Sharing Settings → Public → Get Embed Code', + placeholder: 'https://www.beautiful.ai/player/-AbCd...', + example: 'https://www.beautiful.ai/player/-demoBeautifulAiId', + steps: [ + 'Open Sharing Settings and switch the deck to Public.', + 'Click Get Embed Code (or copy the public player link).', + 'Paste the link or embed code here.' + ], + test: (v) => /beautiful\.ai/i.test(v), + resolve(v) { + let u = iframeSrc(v) || v.trim(); + if (!/beautiful\.ai/i.test(u)) return err('That doesn\u2019t look like a Beautiful.ai link.'); + return ok(u); + } + } + ]; + + const byId = Object.fromEntries(PLATFORMS.map((p) => [p.id, p])); + function ok(src) { return { status: 'ok', src }; } + function err(message) { return { status: 'err', message }; } + function docsFor(p) { return DOCS + '#' + p.id; } + + function detectPlatform(raw) { + if (!raw || !raw.trim()) return null; + return PLATFORMS.find((p) => p.test(raw)) || null; + } + + // -------- shared preview renderer -------- + function previewMarkup(p, res) { + if (!res || res.status === 'empty') { + return `
+
+
Paste your ${p ? p.name : 'slide'} link to preview it here
+
`; + } + if (res.status === 'err') { + return `
+
${iconAlert()}
+
${res.message}
+
`; + } + const brand = PLAT[p.id].bg; + return `
+
+
+
+ + ${p.name} +
+
+
+
Embedded deck
+
Q4 Product Review
+
+
+
+
+
+
+
+ ${iconCheck()} Embed ready + ${res.src} +
+
`; + } + + /* ===================================================================== + * Stage chrome (tabs) shared by every variant + * ===================================================================== */ + function chrome(inner, subtitle) { + return `
+ +
+
+

Add a slide deck

+

${subtitle}

+
+
${inner}
+
+
`; + } + + /* ===================================================================== + * VARIANT 1 — GALLERY (browse tiles → focus into per-platform input) + * ===================================================================== */ + const Gallery = (() => { + let selected = null; + function tiles() { + return `
` + PLATFORMS.map((p, i) => + ``).join('') + `
`; + } + function detail(p) { + return `
+ +
+ ${badge(p.id, 48)} +

${p.name}

${p.hint}
+
+
+
+ + ${p.kind === 'code' + ? `` + : ``} +
${p.hint}.
+ +
+
+
Live preview
+
${previewMarkup(p, { status: 'empty' })}
+
+
+
`; + } + return { + render() { + return chrome(selected ? detail(byId[selected]) : tiles(), + selected ? 'Paste your link and preview it before saving.' + : 'Pick where your slides live \u2014 we\u2019ll show you exactly what to paste.'); + }, + init(root) { + root.querySelectorAll('[data-pick]').forEach((el) => + el.addEventListener('click', () => { selected = el.dataset.pick; remount(); })); + const back = root.querySelector('[data-back]'); + if (back) back.addEventListener('click', () => { selected = null; remount(); }); + const input = root.querySelector('#g-input'); + if (input) { + const p = byId[selected]; + const preview = root.querySelector('#g-preview'); + const update = () => { + const raw = input.value; + const res = raw.trim() ? p.resolve(raw) : { status: 'empty' }; + preview.innerHTML = previewMarkup(p, res); + }; + input.addEventListener('input', update); + const fill = root.querySelector('[data-fill]'); + if (fill) fill.addEventListener('click', () => { input.value = p.example; update(); input.focus(); }); + } + } + }; + })(); + + /* ===================================================================== + * VARIANT 2 — COMMAND BAR (paste first → auto-detect → preview) + * ===================================================================== */ + const Command = (() => { + return { + render() { + return chrome(`
+
Smart paste
+ +
+ +
+
+
+
Works with
+
` + + PLATFORMS.map((p) => ``).join('') + + `
+
+
`, 'One field. Paste your deck and we detect the platform automatically.'); + }, + init(root) { + const input = root.querySelector('#c-input'); + const result = root.querySelector('#c-result'); + const update = () => { + const raw = input.value; + if (!raw.trim()) { result.innerHTML = ''; return; } + const p = detectPlatform(raw); + if (!p) { + result.innerHTML = `
+ ${iconAlert()} +
Platform not recognized yetCheck the supported list below, or see all platforms in the docs.
+ ${iconExternal()} Docs +
`; + return; + } + const res = p.resolve(raw); + result.innerHTML = `
+ ${badge(p.id, 34)} +
${p.name} detected + ${res.status === 'ok' ? 'Ready to embed \u2014 preview below.' : res.message} +
+ ${iconExternal()} ${p.name} guide +
+
${previewMarkup(p, res)}
`; + }; + input.addEventListener('input', update); + root.querySelectorAll('[data-ex]').forEach((el) => + el.addEventListener('click', () => { input.value = byId[el.dataset.ex].example; update(); input.focus(); })); + } + }; + })(); + + /* ===================================================================== + * VARIANT 3 — GUIDED (searchable rail + step-by-step instructions) + * ===================================================================== */ + const Guided = (() => { + let selected = null; + let query = ''; + function listItems() { + const q = query.trim().toLowerCase(); + const items = PLATFORMS.filter((p) => p.name.toLowerCase().includes(q)); + if (!items.length) return `
No matches
`; + return items.map((p) => + ``).join(''); + } + function pane() { + if (!selected) { + return `
+
+ +
+
Pick a platform to see step-by-step
instructions and paste your link.
+
`; + } + const p = byId[selected]; + return `
+
+ ${badge(p.id, 44)} +

${p.name}

${p.kind === 'code' ? 'Needs the full embed code' : 'Needs a public share link'}
+
+
How to get your embed link
+
    ${p.steps.map((s, i) => + `
  1. ${i + 1}${s}
  2. `).join('')}
+ + ${p.kind === 'code' + ? `` + : ``} +
+ ${iconExternal()} Open ${p.name} docs + +
+
Live preview
+
${previewMarkup(p, { status: 'empty' })}
+
`; + } + return { + render() { + return chrome(`
+
+
+ +
${listItems()}
+
+
${pane()}
+
+
`, 'A guided path \u2014 search, follow the steps, paste, done.'); + }, + init(root) { + const search = root.querySelector('#w-search'); + const list = root.querySelector('#w-list'); + const paneEl = root.querySelector('#w-pane'); + function wireList() { + list.querySelectorAll('[data-sel-id]').forEach((el) => + el.addEventListener('click', () => { selected = el.dataset.selId; paneEl.innerHTML = pane(); wirePane(); syncSel(); })); + } + function syncSel() { + list.querySelectorAll('[data-sel-id]').forEach((el) => + el.toggleAttribute('data-sel', el.dataset.selId === selected)); + } + function wirePane() { + const input = paneEl.querySelector('#w-input'); + if (!input) return; + const p = byId[selected]; + const preview = paneEl.querySelector('#w-preview'); + const update = () => { + const raw = input.value; + preview.innerHTML = previewMarkup(p, raw.trim() ? p.resolve(raw) : { status: 'empty' }); + }; + input.addEventListener('input', update); + const fill = paneEl.querySelector('[data-fill]'); + if (fill) fill.addEventListener('click', () => { input.value = p.example; update(); input.focus(); }); + } + search.addEventListener('input', () => { + query = search.value; + list.innerHTML = listItems(); + wireList(); + }); + // keep caret focus stable after re-render + search.addEventListener('keyup', () => {}); + wireList(); + wirePane(); + } + }; + })(); + + function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + /* ===================================================================== + * Picker wiring (behavior per PICKER.md, expressed with an init hook) + * ===================================================================== */ + const variants = [Gallery, Command, Guided]; + const stage = document.getElementById('stage'); + const picker = document.querySelector('.proto-picker'); + const highlight = picker.querySelector('.proto-picker-highlight'); + const items = [...picker.querySelectorAll('.proto-picker-item:not(.proto-picker-replay)')]; + const replay = picker.querySelector('.proto-picker-replay'); + let current = 0; + + function moveHighlight() { + const el = items[current]; + highlight.style.width = el.offsetWidth + 'px'; + highlight.style.transform = `translateX(${el.offsetLeft}px)`; + } + function mount(i) { + stage.innerHTML = ''; + requestAnimationFrame(() => { + stage.innerHTML = variants[i].render(); + variants[i].init(stage); + }); + } + function remount() { // used by variants that hold internal state + stage.innerHTML = variants[current].render(); + variants[current].init(stage); + } + function setActive(i) { + if (i < 0 || i >= variants.length) return; + current = i; + items.forEach((el, j) => { + el.toggleAttribute('data-active', j === i); + if (j === i) el.setAttribute('aria-current', 'true'); + else el.removeAttribute('aria-current'); + }); + moveHighlight(); + const url = new URL(location); + url.searchParams.set('v', i + 1); + history.replaceState(null, '', url); + mount(i); + } + + items.forEach((el, i) => el.addEventListener('click', () => setActive(i))); + replay?.addEventListener('click', () => mount(current)); + window.addEventListener('resize', moveHighlight); + document.addEventListener('keydown', (e) => { + if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const num = parseInt(e.key, 10); + if (num >= 1 && num <= variants.length) setActive(num - 1); + else if (e.key === 'ArrowRight') setActive((current + 1) % variants.length); + else if (e.key === 'ArrowLeft') setActive((current - 1 + variants.length) % variants.length); + else if (e.key === 'r' || e.key === 'R') mount(current); + }); + + setActive((parseInt(new URLSearchParams(location.search).get('v'), 10) || 1) - 1); + requestAnimationFrame(() => requestAnimationFrame(() => picker.setAttribute('data-ready', ''))); + + +