feat(v5.10.0): v5.4.0 + v5.5.0 features — backlinks, page/collection duplication, trash purge, version history, markdown import, embed/bookmark/video/audio blocks, cover & icon, OG metadata
- Migration v7: page_versions table + pages.cover_url/page_icon columns - Trash service (purge_expired 30-day) + daily scheduler - Board API: duplicate page, backlinks, versions (snapshot/list/restore), cover/icon upload, markdown/file import - Collections API: duplicate collection (deep copy properties/views/pages/data sources) - Export service: render embed/bookmark/video/audio in markdown/HTML/PDF/public - Public page renderer: cover image + icon - Editor: slash commands for media blocks, lightbox, version history UI, backlinks panel, cover/icon picker, import modal - OG metadata endpoint for bookmark cards - 386 tests passing
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
<div class="page-editor-wrapper" x-data="editorState()">
|
||||
<div
|
||||
class="page-editor-wrapper"
|
||||
x-data="editorState()"
|
||||
x-init="loadCoverIcon()"
|
||||
>
|
||||
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
||||
|
||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||
@@ -250,6 +254,46 @@
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- v5.5.0: Cover & Icon -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">{{ fd_icon('image',14) }} Cover & Icon</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="sd-btn-secondary" @click="toggleCover()">
|
||||
<span x-show="!coverOpen">{{ fd_icon('image',14) }} Cover</span>
|
||||
<span x-show="coverOpen">{{ fd_icon('x',14) }} Hide</span>
|
||||
</button>
|
||||
<button class="sd-btn-secondary" @click="toggleIcon()">
|
||||
<span x-show="!iconOpen">{{ fd_icon('sparkles',14) }} Icon</span>
|
||||
<span x-show="iconOpen">{{ fd_icon('x',14) }} Hide</span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="coverOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
|
||||
<input type="text" class="sd-input" x-model="coverUrl" placeholder="Cover image URL" style="flex:1;min-width:200px;">
|
||||
<button class="sd-btn-primary" @click="setCoverUrl()" :disabled="coverLoading" x-show="!coverLoading">Set</button>
|
||||
<button class="sd-btn-primary" :disabled="coverLoading" x-show="coverLoading">Saving…</button>
|
||||
<label class="sd-btn-secondary" style="display:flex;align-items:center;gap:6px;">
|
||||
{{ fd_icon('upload',14) }} Upload
|
||||
<input type="file" accept="image/*" @change="uploadCover($event)" style="display:none;">
|
||||
</label>
|
||||
<button class="sd-btn-danger" @click="removeCover()" x-show="coverUrl">Remove</button>
|
||||
</div>
|
||||
<div x-show="coverUrl" style="max-width:320px;border-radius:8px;overflow:hidden;border:1px solid var(--border);">
|
||||
<img :src="coverUrl" style="width:100%;height:auto;display:block;">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<template x-for="ic in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ic">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
@@ -395,6 +439,15 @@
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="toggleBacklinks">
|
||||
{{ fd_icon("link-2",14) }} Linked from
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleVersions">
|
||||
{{ fd_icon("history",14) }} Version history
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleImport">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
<div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div>
|
||||
<template x-if="exportOpen">
|
||||
<div class="more-menu-sub">
|
||||
@@ -439,21 +492,23 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area">
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji">{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div class="page-cover-area" x-show="coverUrl" style="position:relative;height:240px;overflow:hidden;">
|
||||
<img :src="coverUrl" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" x-text="iconValue || (page.content_format == 'file' ? fd_icon('paperclip',14) : fd_icon('file',14))"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -669,6 +724,97 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
|
||||
<div class="versions-popover"
|
||||
x-show="versionsOpen"
|
||||
@click.outside="versionsOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:380px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('history',14) }} Version history</span>
|
||||
<button class="topbar-btn" @click="versionsOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="versionsLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!versionsLoading && versionsList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No versions yet</div>
|
||||
</template>
|
||||
<template x-for="v in versionsList" :key="v.id">
|
||||
<div class="version-item"
|
||||
style="padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;"
|
||||
@click="restoreVersion(v.id)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="v.note || 'Edited'"></div>
|
||||
<span style="font-size:12px;color:var(--text-dim);" x-text="v.author ? v.author + ' · ' : ''"></span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;" x-text="formatDate(v.created_at)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Backlinks Popover ═══════════ -->
|
||||
<div class="backlinks-popover"
|
||||
x-show="backlinksOpen"
|
||||
@click.outside="backlinksOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:360px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('link-2',14) }} Linked from</span>
|
||||
<button class="topbar-btn" @click="backlinksOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="backlinksLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!backlinksLoading && backlinksList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Import Modal ═══════════ -->
|
||||
<div class="import-modal"
|
||||
x-show="importOpen"
|
||||
@click.self="importOpen = false"
|
||||
x-transition.opacity
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;">
|
||||
<div class="import-box" style="width:560px;max-width:94vw;max-height:76vh;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text);">{{ fd_icon('download',14) }} Import page</h3>
|
||||
<button class="flowdeck-modal-close" @click="importOpen = false">×</button>
|
||||
</div>
|
||||
<div style="padding:20px;overflow-y:auto;flex:1;">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
<!-- Markdown text import -->
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">Markdown text</label>
|
||||
<textarea x-model="importText" rows="10" placeholder="Paste Markdown here…" style="width:100%;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:vertical;outline:none;box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import as new page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
|
||||
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
|
||||
<div x-show="importFile" style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
{id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'},
|
||||
{id:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'},
|
||||
]},
|
||||
{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||
{name:'MEDIA',items:[
|
||||
{id:'image',name:'Image',icon:'🖼️'},{id:'embed',name:'Embed',icon:'🔗'},
|
||||
{id:'bookmark',name:'Bookmark',icon:'🔖'},{id:'video',name:'Video',icon:'🎬'},
|
||||
{id:'audio',name:'Audio',icon:'🎵'},
|
||||
]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
|
||||
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
||||
|
||||
@@ -199,7 +203,7 @@
|
||||
}
|
||||
|
||||
// ── Block type shortcuts (shown on the right of each slash item) ──
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
|
||||
// ── Hover preview card (matches Notion's visual hint) ──
|
||||
function _previewHtml(blockId){
|
||||
@@ -222,6 +226,10 @@
|
||||
case 'code': return p('<pre style="background:#111;padding:10px;border-radius:6px;font-size:12px"><code>const x = 1;</code></pre>');
|
||||
case 'table': return p('<div style="font-size:12px"><table style="width:100%;border-collapse:collapse"><tr><td style="border:1px solid var(--border);padding:4px">A</td><td style="border:1px solid var(--border);padding:4px">B</td></tr><tr><td style="border:1px solid var(--border);padding:4px">1</td><td style="border:1px solid var(--border);padding:4px">2</td></tr></table></div>');
|
||||
case 'image': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">[Image]</div>');
|
||||
case 'embed': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🔗 Embed (YouTube, Figma, Maps…)</div>');
|
||||
case 'bookmark': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🔖 Bookmark card with preview</div>');
|
||||
case 'video': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🎬 Inline video player</div>');
|
||||
case 'audio': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🎵 Inline audio player</div>');
|
||||
case 'database': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px">📊 Inline database table</div>');
|
||||
default: return p('<div style="font-size:13px;color:var(--text-dim)">Text block</div>');
|
||||
}
|
||||
@@ -408,10 +416,20 @@
|
||||
if(b.type==='table')return renderTableBlock(b,idx);
|
||||
if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`;
|
||||
if(b.type==='button')return renderAutomationButton(b,idx);
|
||||
if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='table_of_contents')return renderTOC(b);
|
||||
if(b.type==='math')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||
if(b.type==='columns')return renderColumnsBlock(b);
|
||||
if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" class="fd-img" data-full="${b.src||''}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;cursor:zoom-in;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'" onclick="E._openLightbox(this)"></div></div>`;
|
||||
if(b.type==='video'){
|
||||
const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video"><video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:0 auto;"><source src="${src}"></video></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setVideoSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎬</div><div style="font-size:14px;color:var(--text-dim);">Click to add video URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='audio'){
|
||||
const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-audio"><audio controls preload="metadata" style="width:100%;"><source src="${src}"></audio></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-audio" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setAudioSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎵</div><div style="font-size:14px;color:var(--text-dim);">Click to add audio URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='bookmark'){
|
||||
var url=b.url||b.src||'';var title=esc(b.title||url);var desc=esc(b.description||'');var img=b.image||'';var site=esc(b.site_name||'');
|
||||
var imgHtml=img?'<img src="'+img+'" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">':'';
|
||||
var descHtml=desc?'<div style="font-size:13px;color:var(--text-dim);margin-top:4px;">'+desc+'</div>':'';
|
||||
var siteHtml=site?'<div style="font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">'+site+'</div>':'';
|
||||
return url?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark"><a href="'+url+'" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;"><div style="display:flex;gap:14px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:0;background:var(--bg-secondary);"><div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">'+title+'</div>'+descHtml+siteHtml+'</div>'+imgHtml+'</div></a></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setBookmarkUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔖</div><div style="font-size:14px;color:var(--text-dim);">Click to add bookmark URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='embed'){
|
||||
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
|
||||
if(b.embed_type==='collection')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script></div>`;
|
||||
@@ -420,6 +438,21 @@
|
||||
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
|
||||
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 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>`;
|
||||
}
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
|
||||
}
|
||||
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
|
||||
if(b.embed_type==='collection')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script></div>`;
|
||||
if(b.embed_type==='pdf')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
|
||||
if(b.embed_type==='download'){
|
||||
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
|
||||
let inner='';
|
||||
@@ -985,10 +1018,183 @@
|
||||
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
||||
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pushHistory();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();this.pushHistory();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();this.pushHistory();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();this._rtSync();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pushHistory();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}if(type==='embed'){b.embed_type='';b.src='';b.height=520;}if(type==='bookmark'){b.url='';b.title='';b.description='';b.image='';b.site_name='';}if(type==='video'){b.src='';b.title='';}if(type==='audio'){b.src='';b.title='';}this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();this.pushHistory();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();this.pushHistory();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();this._rtSync();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
|
||||
// ── v5.4.0/v5.5.0: Media block URL setters (click empty block → prompt) ──
|
||||
_mediaPrompt(bid, field, label){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
const cur=b[field]||''; const u=prompt(label+' (URL):', cur);
|
||||
if(u===null)return;
|
||||
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');},
|
||||
setBookmarkUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
const cur=b.url||''; const u=prompt('Bookmark URL:', cur);
|
||||
if(u===null)return;
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b.url=u.trim();
|
||||
this._fetchBookmarkPreview(bid, u.trim());
|
||||
},
|
||||
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 d=await r.json();
|
||||
if(d&&d.ok){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx];
|
||||
if(d.title)b.title=d.title;
|
||||
if(d.description)b.description=d.description;
|
||||
if(d.image)b.image=d.image;
|
||||
if(d.site_name)b.site_name=d.site_name;
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
}
|
||||
}catch(e){}
|
||||
},
|
||||
setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');},
|
||||
setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');},
|
||||
|
||||
// ── v5.5.0: Image lightbox ──
|
||||
_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();};
|
||||
document.addEventListener('keydown',kd);
|
||||
ov.addEventListener('click',e=>{if(e.target===ov)close();});
|
||||
},
|
||||
|
||||
// ── v5.4.0: Version history UI ──
|
||||
versionsOpen:false,versionsLoading:false,versionsList:[],
|
||||
async toggleVersions(){
|
||||
this.versionsOpen=!this.versionsOpen;
|
||||
if(this.versionsOpen){await this.loadVersions();}
|
||||
},
|
||||
async loadVersions(){
|
||||
this.versionsLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions');
|
||||
const d=await r.json();
|
||||
this.versionsList=d.versions||[];
|
||||
}catch(e){this.versionsList=[];}
|
||||
this.versionsLoading=false;
|
||||
},
|
||||
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 d=await r.json();
|
||||
if(d.status==='ok'){
|
||||
this.showToast('Version restored');this.versionsOpen=false;
|
||||
setTimeout(()=>window.location.reload(),300);
|
||||
}
|
||||
}catch(e){this.showToast('Restore failed','error');}
|
||||
},
|
||||
|
||||
// ── v5.4.0: Backlinks ──
|
||||
backlinksOpen:false,backlinksLoading:false,backlinksList:[],
|
||||
async toggleBacklinks(){
|
||||
this.backlinksOpen=!this.backlinksOpen;
|
||||
if(this.backlinksOpen){await this.loadBacklinks();}
|
||||
},
|
||||
async loadBacklinks(){
|
||||
this.backlinksLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/backlinks');
|
||||
const d=await r.json();
|
||||
this.backlinksList=d.backlinks||[];
|
||||
}catch(e){this.backlinksList=[];}
|
||||
this.backlinksLoading=false;
|
||||
},
|
||||
|
||||
// ── v5.5.0: Cover & Icon ──
|
||||
coverOpen:false,coverUrl:'',coverLoading:false,iconOpen:false,iconValue:'',
|
||||
async loadCoverIcon(){
|
||||
try{
|
||||
const r=await fetch('/board/api/pages/'+this.pid);
|
||||
const d=await r.json();
|
||||
this.coverUrl=d.cover_url||'';
|
||||
this.iconValue=d.page_icon||'';
|
||||
}catch(e){}
|
||||
},
|
||||
toggleCover(){this.coverOpen=!this.coverOpen;if(this.coverOpen&&!this.coverUrl){this.loadCoverIcon();}},
|
||||
async setCoverUrl(){
|
||||
const u=prompt('Cover image URL:', this.coverUrl);
|
||||
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 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');}
|
||||
this.coverLoading=false;
|
||||
},
|
||||
async uploadCover(e){
|
||||
const f=e.target.files?.[0];if(!f)return;
|
||||
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 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');}
|
||||
this.coverLoading=false;
|
||||
e.target.value='';
|
||||
},
|
||||
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 d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');}
|
||||
}catch(e){this.showToast('Remove failed','error');}
|
||||
},
|
||||
toggleIcon(){this.iconOpen=!this.iconOpen;},
|
||||
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 d=await r.json();
|
||||
if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');}
|
||||
}catch(e){this.showToast('Icon update failed','error');}
|
||||
},
|
||||
|
||||
// ── v5.4.0: Import ──
|
||||
importOpen:false,importFile:null,importText:'',
|
||||
toggleImport(){this.importOpen=!this.importOpen;},
|
||||
handleImportFile(e){
|
||||
const f=e.target.files?.[0];if(!f)return;
|
||||
this.importFile=f;
|
||||
const reader=new FileReader();
|
||||
reader.onload=()=>{this.importText=reader.result;};
|
||||
reader.readAsText(f);
|
||||
},
|
||||
async doImport(){
|
||||
if(!this.importText.trim()&&!this.importFile){this.showToast('Nothing to import');return;}
|
||||
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 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 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');}
|
||||
}
|
||||
},
|
||||
|
||||
// ── v5.10.0: Undo / Redo ──
|
||||
pushHistory(){if(this._histLock)return;this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));if(this.undoStack.length>this._histCap)this.undoStack.shift();this.redoStack.length=0;},
|
||||
@@ -1479,6 +1685,10 @@ applyAIBlocks(text){
|
||||
if(id==='turn_into'){const blk=this.blocks[idx];if(!blk)return;const el=this.getEl(blk.id);const rk=el?el.getBoundingClientRect():null;blockMenuOpen(rk?rk.left:window.innerWidth/2,rk?rk.bottom:window.innerHeight/2,blk.id);return;}
|
||||
const block=this.blocks[idx];this.sync();this.pushHistory();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else if(id==='embed'){block.type='embed';block.embed_type='';block.src='';block.height=520;}
|
||||
else if(id==='bookmark'){block.type='bookmark';block.url='';block.title='';block.description='';block.image='';block.site_name='';}
|
||||
else if(id==='video'){block.type='video';block.src='';block.title='';}
|
||||
else if(id==='audio'){block.type='audio';block.src='';block.title='';}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}}
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
||||
|
||||
@@ -137,7 +137,16 @@
|
||||
</header>
|
||||
|
||||
<main class="pub-container">
|
||||
<h1 class="pub-title">{{ title }}</h1>
|
||||
{% if cover_url %}
|
||||
<div class="pub-cover" style="position:relative;margin:-40px -24px 32px;height:240px;overflow:hidden;">
|
||||
<img src="{{ cover_url }}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.5) 100%);"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<span class="pub-icon" style="font-size:2rem;">{{ page_icon or fd_icon('file',24) }}</span>
|
||||
<h1 class="pub-title">{{ title }}</h1>
|
||||
</div>
|
||||
{% if updated_at %}
|
||||
<div class="pub-meta">Last updated: {{ updated_at[:10] }}</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user