From b1304b76e89b70530302f5474fe1595969a84f16 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 10 Jul 2026 10:37:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Notion=20page=20editor=20=E2=80=94=20to?= =?UTF-8?q?p=20bar,=20share=20modal,=20star=20toggle,=20empty=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates Notion's page layout from reference images: - Top bar: Private badge, Share button, Link copy, Star/favorite toggle, ⋮ menu - Share modal: Publish toggle, Copy link, Invite people with permission select - Empty page toolbar: Get started with H1/H2/H3/bullet/todo/callout/quote - Star toggle integrated with favorites API (POST/DELETE /board/api/favorites) - board.py view_page now passes workspace context + page_favorited - dashboard.py view_page_root also passes page_favorited - CSS: .page-topbar, .share-modal, .empty-page-toolbar, toggle switch, more menu --- app/routers/board.py | 15 ++- app/routers/dashboard.py | 2 +- app/templates/page_editor.html | 170 +++++++++++++++++++++++++++++---- static/css/app.css | 144 ++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 24 deletions(-) diff --git a/app/routers/board.py b/app/routers/board.py index 15bfc4b..3a281a2 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -720,8 +720,19 @@ async def view_page(request: Request, page_id: int): row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: return HTMLResponse("

Page not found

", status_code=404) - sidebar = _sidebar_data(request) - ctx = {**sidebar, "page": dict(row)} + page = dict(row) + ws = page.get("workspace", "") + parts = ws.split("/") if "/" in ws else ["", ""] + owner, repo = parts[0], parts[1] if len(parts) > 1 else "" + sidebar = _sidebar_data(request, owner, repo) + # Check if page is favorited + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + fav = conn.execute( + "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id) + ).fetchone() + ctx = {**sidebar, "page": page, "page_favorited": fav is not None} template = env.get_template("page_editor.html") return template.render(**ctx) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 63898bf..fb657b2 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -166,7 +166,7 @@ async def view_page_root(request: Request, page_id: int): "SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC", (page_id,), ).fetchall() - ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs]} + ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], "page_favorited": False} template = env.get_template("page_editor.html") return template.render(**ctx) diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index 1cf8ff6..229dd6e 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -3,8 +3,100 @@ {% block page_title %}{{ page.title }}{% endblock %} {% block content %} -
+
+ +
+
+ + + + ▾ + +
+
+ + + + + +
+
↗ Export
+
📋 Duplicate
+
↗ Move to
+
+
🗑️ Move to Trash
+
+
+
+ + + + +
📄 @@ -16,6 +108,18 @@
+ +
+ Get started with + + + + + + + +
+
@@ -52,7 +156,6 @@ function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);} function esc(s){return s.replace(/&/g,'&').replace(//g,'>');} -// Slash menu — event delegation on list, single innerHTML set const SM={_o:false,_i:-1,_s:0,_q:'',_it:[], init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML=''; const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...'; @@ -60,7 +163,6 @@ const SM={_o:false,_i:-1,_s:0,_q:'',_it:[], inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev); const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp); const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px'; - // Event delegation: single click handler for all items list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}}); e.appendChild(hdr);e.appendChild(list); SM._e=e;SM._in=inp;SM._li=list; @@ -108,10 +210,18 @@ function renderBlock(b,idx){ } window.E=null; -function editor(pid,title,fmt,raw){ +function editor(pid,title,fmt,raw,favorited){ const self={ pid,pageTitle:title,blocks:[],dirty:false,saving:false,lastSaved:'',st:null, fmt:{open:false,top:0,left:0,idx:-1}, + // ── Page actions state ── + shareOpen:false,moreOpen:false, + favorited:!!favorited, + pagePublished:false, + pageUrl:window.location.href, + publishedUrl:'', + inviteEmail:'',invitePermission:'editor', + accessList:[], init(){ _bid=Math.floor(Math.random()*10000); @@ -134,20 +244,52 @@ function editor(pid,title,fmt,raw){ focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}}, onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}, - // Sync DOM→data, render, add listeners sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}}, render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};}); - // Update empty class: browser may insert
in contentEditable — strip and apply class ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); }, + replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, + addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();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();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();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, - // KEY HANDLER + // ── Page actions ── + toggleFavorite(){ + const csrf=document.cookie.match(/csrf_token=([^;]+)/); + const m=this.favorited?'DELETE':'POST'; + fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}}) + .then(r=>r.json()).then(()=>{this.favorited=!this.favorited;}).catch(()=>{alert('Failed');}); + }, + copyPageLink(){ + navigator.clipboard.writeText(this.pageUrl).then(()=>{ + const btn=document.querySelector('.page-action-btn[title="Copy link"]'); + if(btn){btn.textContent='✓';setTimeout(()=>{btn.textContent='🔗';},1500);} + }).catch(()=>{alert('Failed to copy');}); + }, + togglePublish(){ + const csrf=document.cookie.match(/csrf_token=([^;]+)/); + fetch(`/board/api/pages/${this.pid}/publish`,{method:this.pagePublished?'POST':'DELETE',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}) + .then(r=>r.json()).then(d=>{if(d.url)this.publishedUrl=d.url;}).catch(()=>{this.pagePublished=!this.pagePublished;}); + }, + invitePerson(){ + if(!this.inviteEmail.trim())return; + const csrf=document.cookie.match(/csrf_token=([^;]+)/); + fetch(`/board/api/pages/${this.pid}/share`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({email:this.inviteEmail,permission:this.invitePermission})}) + .then(r=>r.json()).then(()=>{this.accessList.push({email:this.inviteEmail,permission:this.invitePermission});this.inviteEmail='';}).catch(()=>{}); + }, + removeAccess(email){ + this.accessList=this.accessList.filter(a=>a.email!==email); + }, + exportPage(){this.moreOpen=false;alert('Export (à implémenter)');}, + duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{alert('Duplicate failed');});}, + movePage(){this.moreOpen=false;alert('Move to (à implémenter)');}, + deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{alert('Failed');});}, + + // KEY HANDLER (unchanged) onKd(e){ const ab=this.getActiveBlock();if(!ab)return; const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return; @@ -159,15 +301,9 @@ function editor(pid,title,fmt,raw){ return; } - // Slash detection — use idx, not bid (bid can get stale after render) if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);} - - // ENTER: split block - - // Markdown shortcuts on space — use idx if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);} - // ENTER: split block if(e.key==='Enter'){ if(e.shiftKey){this.dirty=true;this.autoSave();return;} e.preventDefault(); @@ -184,17 +320,10 @@ function editor(pid,title,fmt,raw){ this.dirty=true;this.autoSave();return; } - // ARROWS if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;} if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;} - - // Markdown shortcuts on space if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);} - - // Ctrl shortcuts if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}} if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');} this.dirty=true;this.autoSave(); @@ -235,7 +364,6 @@ function editor(pid,title,fmt,raw){ 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'].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(); - // Update sidebar items with new title 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';}); }).catch(()=>{this.saving=false;}); }, diff --git a/static/css/app.css b/static/css/app.css index 7fbf4f1..b2088ec 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2159,3 +2159,147 @@ input, select, textarea { font-family: inherit; color: inherit; } .block-actions { display: none; } .editor-statusbar { left: 0; } } + +/* ═══════════ Page Editor — Top Bar ═══════════ */ +.page-topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 16px; margin-bottom: 0; + border-bottom: 1px solid var(--border); + min-height: 40px; +} +.page-topbar-left { display: flex; align-items: center; gap: 12px; } +.page-topbar-right { display: flex; align-items: center; gap: 4px; } +.page-privacy-badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 6px; + font-size: 13px; color: var(--text-dim); cursor: pointer; + background: var(--bg-secondary); +} +.page-privacy-badge:hover { background: var(--bg-hover); } +.page-privacy-badge .chevron-down { font-size: 10px; opacity: 0.6; } +.page-action-btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 6px 12px; border: none; border-radius: 6px; + background: transparent; color: var(--text-primary); + font-size: 13px; cursor: pointer; white-space: nowrap; +} +.page-action-btn:hover { background: var(--bg-hover); } +.page-action-btn .chevron-down { font-size: 9px; opacity: 0.5; margin-left: 2px; } +.star-btn { font-size: 16px; padding: 4px 8px; } +.star-btn .starred { color: #f5c542; } + +/* ── More menu dropdown ── */ +.more-menu { + position: absolute; top: 100%; right: 0; + background: var(--bg-secondary); border: 1px solid var(--border); + border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); + min-width: 200px; z-index: 100; padding: 4px 0; +} +.more-menu-item { + padding: 8px 14px; font-size: 13px; color: var(--text-primary); + cursor: pointer; display: flex; align-items: center; gap: 8px; +} +.more-menu-item:hover { background: var(--bg-hover); } +.more-menu-item.danger { color: #e03e3e; } +.more-menu-sep { height: 1px; background: var(--border); margin: 4px 0; } + +/* ═══════════ Share Modal ═══════════ */ +.share-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; + z-index: 200; +} +.share-modal { + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: 12px; width: 480px; max-width: 90vw; + max-height: 80vh; overflow-y: auto; padding: 24px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} +.share-modal-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; +} +.share-modal-header h2 { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; } +.share-modal-close { + background: none; border: none; color: var(--text-dim); + font-size: 18px; cursor: pointer; padding: 4px; +} +.share-modal-close:hover { color: var(--text-primary); } +.share-section { margin: 16px 0; } +.share-section-title { + font-size: 14px; font-weight: 500; color: var(--text-primary); + display: flex; align-items: center; justify-content: space-between; +} +.share-section-desc { + font-size: 12px; color: var(--text-dim); margin-top: 4px; + line-height: 1.4; +} +.share-sep { height: 1px; background: var(--border); margin: 16px 0; } +.share-link-row { display: flex; gap: 8px; margin-top: 8px; } +.share-link-input { + flex: 1; padding: 8px 12px; background: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 6px; + color: var(--text-primary); font-size: 13px; outline: none; +} +.share-copy-btn { + padding: 8px 16px; background: var(--blue); color: #fff; + border: none; border-radius: 6px; cursor: pointer; font-size: 13px; +} +.share-copy-btn:hover { opacity: 0.9; } +.share-invite-row { display: flex; gap: 8px; margin-top: 8px; } +.share-invite-input { + flex: 1; padding: 8px 12px; background: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 6px; + color: var(--text-primary); font-size: 13px; outline: none; +} +.share-permission-select { + padding: 8px 12px; background: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 6px; + color: var(--text-primary); font-size: 13px; outline: none; +} +.share-invite-btn { + padding: 8px 16px; background: var(--blue); color: #fff; + border: none; border-radius: 6px; cursor: pointer; font-size: 13px; +} +.share-access-list { margin-top: 8px; } +.share-access-item { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 0; font-size: 13px; color: var(--text-primary); +} +.share-access-remove { + background: none; border: none; color: var(--text-dim); + cursor: pointer; font-size: 14px; +} +.share-access-remove:hover { color: #e03e3e; } + +/* ── Toggle switch ── */ +.toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; } +.toggle-switch input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; inset: 0; cursor: pointer; + background: var(--bg-secondary); border-radius: 22px; + transition: 0.2s; border: 1px solid var(--border); +} +.toggle-slider:before { + content: ''; position: absolute; height: 16px; width: 16px; + left: 2px; bottom: 2px; background: var(--text-dim); + border-radius: 50%; transition: 0.2s; +} +.toggle-switch input:checked + .toggle-slider { background: var(--blue); } +.toggle-switch input:checked + .toggle-slider:before { transform: translateX(18px); background: #fff; } + +/* ═══════════ Empty Page Toolbar ═══════════ */ +.empty-page-toolbar { + display: flex; align-items: center; gap: 6px; + padding: 12px 0; flex-wrap: wrap; +} +.empty-toolbar-label { + font-size: 13px; color: var(--text-dim); margin-right: 6px; +} +.empty-toolbar-item { + padding: 6px 12px; background: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 6px; + color: var(--text-primary); font-size: 13px; cursor: pointer; + white-space: nowrap; +} +.empty-toolbar-item:hover { background: var(--bg-hover); border-color: var(--text-dim); }