feat(v5.5.0): Embeds & Media riche - universal embeds, bookmark cards, lightbox, inline previews, cover & icon
- embeds.py: provider detection/rewrite (YouTube, Vimeo, Figma, Maps, Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office) + resolve_embed/inline_kind/provider; POST /board/api/embed/resolve - editor resolves pasted URLs and caches embed_src (persisted); renderer, public pages and MD/HTML/PDF exports prefer embed_src - og_fetcher.py: robust meta parsing (any attribute order), favicon, injectable transport, network-safe fallback - image lightbox with keyboard nav (arrows/Esc) in editor and public pages - inline PDF/video/audio previews - cover (URL or upload) & page icon endpoints - fix broken editor API paths (/api/pages -> /board/api/pages) for cover, icon, versions, backlinks, import, move and OG metadata - 47 tests in tests/test_v55.py; full suite 444 green; ruff clean - version 5.11.2
This commit is contained in:
@@ -439,8 +439,7 @@
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
var url=b.src||'';if(url){
|
||||
var src=url;
|
||||
try{var h=window.location.href.split('/').slice(0,3).join('/');var urlNoProto=url.replace(/^https?:\/\//,'');if(/^(youtube\.com|youtu\.be|vimeo\.com|loom\.com|figma\.com|google\.com\/maps|codepen\.io|miro\.com|open\.spotify\.com|soundcloud\.com|twitch\.tv|twitter\.com|x\.com|pinterest\.)/.test(urlNoProto.split('/')[0])){src=url;}}catch(e){}
|
||||
var src=b.embed_src||url;
|
||||
var height=b.height||520;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="position:relative;width:100%;height:${height}px;border-radius:8px;overflow:hidden;background:#0a0a0a;"><iframe src="${src}" loading="lazy" frameborder="0" style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div></div>`;
|
||||
}
|
||||
@@ -1036,7 +1035,20 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b[field]=u.trim();this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
},
|
||||
setEmbedUrl(bid){this._mediaPrompt(bid,'src','Embed');},
|
||||
async setEmbedUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
const cur=b.src||''; const u=prompt('Embed URL (YouTube, Vimeo, Figma, Maps…):', cur);
|
||||
if(u===null)return;
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b.src=u.trim();b.embed_type='';this.dirty=true;
|
||||
try{
|
||||
const r=await fetch('/board/api/embed/resolve',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url:u.trim()})});
|
||||
const d=await r.json();
|
||||
if(d&&d.ok&&d.src){b.embed_src=d.src;b.embed_provider=d.provider||'';}
|
||||
}catch(e){}
|
||||
this.render();this.autoSave();_rtSync();
|
||||
},
|
||||
setBookmarkUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
@@ -1048,7 +1060,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
},
|
||||
async _fetchBookmarkPreview(bid, url){
|
||||
try{
|
||||
const r=await fetch('/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
|
||||
const r=await fetch('/board/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
|
||||
const d=await r.json();
|
||||
if(d&&d.ok){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
@@ -1064,17 +1076,29 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');},
|
||||
setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');},
|
||||
|
||||
// ── v5.5.0: Image lightbox ──
|
||||
// ── v5.5.0: Image lightbox (fullscreen + keyboard navigation) ──
|
||||
_openLightbox(imgEl){
|
||||
const full=imgEl.dataset.full||imgEl.src;
|
||||
if(!full)return;
|
||||
const ov=document.createElement('div');ov.className='fd-lightbox';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
ov.innerHTML='<img src="'+full+'" style="max-width:95vw;max-height:90vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);"><div style="margin-top:16px;color:var(--text-dim);font-size:13px;">Esc to close</div>';
|
||||
document.body.appendChild(ov);
|
||||
const close=()=>{document.body.removeChild(ov);document.removeEventListener('keydown',kd);};
|
||||
const kd=(e)=>{if(e.key==='Escape')close();};
|
||||
const imgs=Array.from(document.querySelectorAll('.block-image img, .fd-img')).filter(i=>i.dataset.full||i.src);
|
||||
let idx=imgs.indexOf(imgEl);
|
||||
if(idx<0)idx=0;
|
||||
const overlay=document.createElement('div');overlay.className='fd-lightbox';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
overlay.innerHTML='<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+'<img class="fd-lightbox-img" style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+'<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+'<div class="fd-lightbox-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
document.body.appendChild(overlay);
|
||||
const img=overlay.querySelector('.fd-lightbox-img');
|
||||
const hint=overlay.querySelector('.fd-lightbox-hint');
|
||||
const show=()=>{const cur=imgs[idx];img.src=cur.dataset.full||cur.src;hint.textContent=(imgs.length>1?(idx+1)+' / '+imgs.length+' — ':'')+'← → to navigate · Esc to close';};
|
||||
const step=(d)=>{if(imgs.length<2)return;idx=(idx+d+imgs.length)%imgs.length;show();};
|
||||
show();
|
||||
const close=()=>{overlay.remove();document.removeEventListener('keydown',kd);};
|
||||
const kd=(e)=>{if(e.key==='Escape')close();else if(e.key==='ArrowRight')step(1);else if(e.key==='ArrowLeft')step(-1);};
|
||||
document.addEventListener('keydown',kd);
|
||||
ov.addEventListener('click',e=>{if(e.target===ov)close();});
|
||||
overlay.querySelectorAll('[data-nav]').forEach(btn=>btn.addEventListener('click',e=>{e.stopPropagation();step(btn.dataset.nav==='next'?1:-1);}));
|
||||
overlay.addEventListener('click',e=>{if(e.target===overlay)close();});
|
||||
},
|
||||
|
||||
// ── v5.4.0: Version history UI ──
|
||||
@@ -1086,7 +1110,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async loadVersions(){
|
||||
this.versionsLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions');
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/versions');
|
||||
const d=await r.json();
|
||||
this.versionsList=d.versions||[];
|
||||
}catch(e){this.versionsList=[];}
|
||||
@@ -1095,7 +1119,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async restoreVersion(vid){
|
||||
if(!confirm('Restore this version? Current content will be replaced.'))return;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){
|
||||
this.showToast('Version restored');this.versionsOpen=false;
|
||||
@@ -1113,7 +1137,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async loadBacklinks(){
|
||||
this.backlinksLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/backlinks');
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/backlinks');
|
||||
const d=await r.json();
|
||||
this.backlinksList=d.backlinks||[];
|
||||
}catch(e){this.backlinksList=[];}
|
||||
@@ -1135,7 +1159,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(u===null)return;
|
||||
this.coverLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover updated');}
|
||||
}catch(e){this.showToast('Cover update failed','error');}
|
||||
@@ -1146,7 +1170,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
this.coverLoading=true;
|
||||
const fd=new FormData();fd.append('file',f);
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover uploaded');}
|
||||
}catch(e){this.showToast('Cover upload failed','error');}
|
||||
@@ -1156,7 +1180,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async removeCover(){
|
||||
if(!confirm('Remove cover?'))return;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');}
|
||||
}catch(e){this.showToast('Remove failed','error');}
|
||||
@@ -1165,7 +1189,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async setIcon(icon){
|
||||
this.iconValue=icon;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');}
|
||||
}catch(e){this.showToast('Icon update failed','error');}
|
||||
@@ -1186,13 +1210,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(this.importFile){
|
||||
const fd=new FormData();fd.append('file',this.importFile);
|
||||
try{
|
||||
const r=await fetch('/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const r=await fetch('/board/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.showToast(d.count+' page(s) imported');this.importOpen=false;this.importFile=null;this.importText='';}
|
||||
}catch(e){this.showToast('Import failed','error');}
|
||||
}else{
|
||||
try{
|
||||
const r=await fetch('/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
|
||||
const r=await fetch('/board/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.showToast('Page imported');this.importOpen=false;this.importText='';setTimeout(()=>{window.location.href='/pages/'+d.id;},400);}
|
||||
}catch(e){this.showToast('Import failed','error');}
|
||||
@@ -1518,7 +1542,7 @@ applyAIBlocks(text){
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/move', {
|
||||
fetch('/board/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
@@ -1941,7 +1965,7 @@ applyAIBlocks(text){
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
if(cb)cb();
|
||||
|
||||
Reference in New Issue
Block a user