The unified _header.html already renders the topbar with breadcrumb. The page_editor had a second 'page-topbar' div in the content area with the same buttons (Share, Copy link, Favorite, More). Removed the duplicate actions from the header, keeping them only in the content area where they are in the correct Alpine scope (editorState()). This fixes: - Double header visual duplication - Share/Copy/Favorite/More buttons now work (in editorState scope)
788 lines
48 KiB
HTML
788 lines
48 KiB
HTML
{% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block
|
|
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|
{% set page_icon = "📄" %}
|
|
{% set page_title = page.title %}
|
|
{% include '_header.html' %}
|
|
{% endblock %} {% block content %}
|
|
<div class="page-editor-wrapper" x-data="editorState()">
|
|
<!-- ═══════════ Action Bar (Share, Copy, Favorite, More) ═══════════ -->
|
|
<div class="page-topbar">
|
|
<div class="page-topbar-right" style="position: relative">
|
|
<span class="topbar-edited">Edited just now</span>
|
|
<button
|
|
class="page-action-btn share-btn"
|
|
@click="shareOpen = !shareOpen"
|
|
>
|
|
🔒 Share <span class="chevron-down">▾</span>
|
|
</button>
|
|
<button
|
|
class="page-action-btn"
|
|
@click="copyPageLink"
|
|
title="Copy link"
|
|
>
|
|
🔗
|
|
</button>
|
|
<button class="page-action-btn star-btn" @click="toggleFavorite">
|
|
<span
|
|
x-text="favorited ? '⭐' : '☆'"
|
|
:class="{ starred: favorited }"
|
|
></span>
|
|
</button>
|
|
<button class="page-action-btn" @click="moreOpen = !moreOpen">
|
|
⋮
|
|
</button>
|
|
|
|
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
|
<div
|
|
class="share-dialog"
|
|
x-show="shareOpen"
|
|
@click.outside="shareOpen = false"
|
|
x-transition.opacity.scale.origin.top.right
|
|
>
|
|
<!-- Header with tabs -->
|
|
<div class="sd-header">
|
|
<button
|
|
class="sd-tab"
|
|
:class="{ active: shareTab === 'share' }"
|
|
@click="shareTab = 'share'"
|
|
>
|
|
Share
|
|
</button>
|
|
<button
|
|
class="sd-tab"
|
|
:class="{ active: shareTab === 'publish' }"
|
|
@click="shareTab = 'publish'"
|
|
>
|
|
Publish
|
|
<span class="sd-badge" x-show="pagePublished"></span>
|
|
</button>
|
|
<button class="sd-close" @click="shareOpen = false">
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════ SHARE TAB ═══════ -->
|
|
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
|
<!-- Invite input -->
|
|
<div class="sd-invite-row">
|
|
<input
|
|
type="email"
|
|
class="sd-input"
|
|
placeholder="Add people, groups or emails..."
|
|
x-model="inviteEmail"
|
|
@keydown.enter="shareInvite()"
|
|
/>
|
|
<button class="sd-btn-primary" @click="shareInvite()">
|
|
Invite
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Context card (shown when independent permissions) -->
|
|
<div class="sd-context-card" x-show="false">
|
|
Share settings on this page are unlinked from parent
|
|
page
|
|
</div>
|
|
|
|
<!-- Participants list -->
|
|
<div class="sd-participants" x-show="accessList.length">
|
|
<template x-for="a in accessList" :key="a.email">
|
|
{% raw %}
|
|
<div class="sd-participant">
|
|
<div class="sd-participant-info">
|
|
<div
|
|
class="sd-avatar"
|
|
x-text="a.email[0].toUpperCase()"
|
|
></div>
|
|
<div>
|
|
<div
|
|
class="sd-participant-name"
|
|
x-text="a.email"
|
|
></div>
|
|
<div
|
|
class="sd-participant-email"
|
|
x-text="a.email"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="sd-perm-btn"
|
|
@click="a.permOpen = !a.permOpen"
|
|
>
|
|
<span
|
|
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
|
|
></span>
|
|
▾
|
|
</button>
|
|
<div
|
|
class="sd-perm-menu"
|
|
x-show="a.permOpen"
|
|
@click.outside="a.permOpen = false"
|
|
>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: a.permission === 'editor' }"
|
|
@click="a.permission='editor'; a.permOpen=false"
|
|
>
|
|
<span>Can edit</span>
|
|
<span class="sd-perm-desc"
|
|
>Edit, suggest and comment</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: a.permission === 'commenter' }"
|
|
@click="a.permission='commenter'; a.permOpen=false"
|
|
>
|
|
<span>Can comment</span>
|
|
<span class="sd-perm-desc"
|
|
>Suggest and comment only</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: a.permission === 'viewer' }"
|
|
@click="a.permission='viewer'; a.permOpen=false"
|
|
>
|
|
<span>Can view</span>
|
|
<span class="sd-perm-desc"
|
|
>Read only</span
|
|
>
|
|
</div>
|
|
<div class="sd-perm-sep"></div>
|
|
<div
|
|
class="sd-perm-option danger"
|
|
@click="removeAccess(a.email); a.permOpen=false"
|
|
>
|
|
Remove
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endraw %}
|
|
</template>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<!-- General Access -->
|
|
<div class="sd-section">
|
|
<div class="sd-section-title">General access</div>
|
|
<div style="position: relative">
|
|
<button
|
|
class="sd-access-btn"
|
|
@click="accessMenuOpen = !accessMenuOpen"
|
|
>
|
|
<span
|
|
x-text="generalAccess === 'invited' ? '🔒 Only people invited' : '🌐 Anyone with the link'"
|
|
></span>
|
|
<span class="chevron-down">▾</span>
|
|
</button>
|
|
<div
|
|
class="sd-access-menu"
|
|
x-show="accessMenuOpen"
|
|
@click.outside="accessMenuOpen = false"
|
|
x-transition
|
|
>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: generalAccess === 'invited' }"
|
|
@click="generalAccess='invited'; accessMenuOpen=false"
|
|
>
|
|
<span>🔒 Only people invited</span>
|
|
<span class="sd-perm-desc"
|
|
>People need to be invited</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: generalAccess === 'anyone' }"
|
|
@click="generalAccess='anyone'; accessMenuOpen=false"
|
|
>
|
|
<span>🌐 Anyone with the link</span>
|
|
<span class="sd-perm-desc"
|
|
>Can view the page</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Public permission when link is open -->
|
|
<div
|
|
x-show="generalAccess === 'anyone'"
|
|
style="
|
|
margin-top: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
"
|
|
>
|
|
<span
|
|
style="
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
"
|
|
>Anyone with the link</span
|
|
>
|
|
<select class="sd-select-sm" x-model="publicPerm">
|
|
<option value="viewer">Can view</option>
|
|
<option value="commenter">Can comment</option>
|
|
<option value="editor">Can edit</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<!-- Footer -->
|
|
<div class="sd-footer">
|
|
<a href="#" class="sd-footer-link" @click.prevent
|
|
>? Learn about sharing</a
|
|
>
|
|
<button class="sd-footer-action" @click="copyPageLink">
|
|
🔗 Copy link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════ PUBLISH TAB ═══════ -->
|
|
<div
|
|
class="sd-body"
|
|
x-show="shareTab === 'publish'"
|
|
x-transition
|
|
>
|
|
<!-- Before publishing -->
|
|
<div x-show="!pagePublished" class="sd-publish-hero">
|
|
<div class="sd-publish-preview">
|
|
<div class="sd-preview-bar"></div>
|
|
<div class="sd-preview-title">
|
|
🌐 {{ page.title }}
|
|
</div>
|
|
</div>
|
|
<h3 class="sd-publish-heading">Publish to web</h3>
|
|
<p class="sd-publish-desc">
|
|
Make this page visible to anyone on the internet.
|
|
You can share the link with anyone.
|
|
</p>
|
|
<button
|
|
class="sd-btn-primary sd-btn-full"
|
|
@click="publishPage()"
|
|
>
|
|
Publish
|
|
</button>
|
|
<p class="sd-publish-note">
|
|
Your page will be visible to anyone with the link.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- After publishing -->
|
|
<div x-show="pagePublished">
|
|
<div class="sd-url-bar">
|
|
<span
|
|
style="font-size: 13px; color: var(--text-dim)"
|
|
>{{ workspace_key or 'flowdeck' }}</span
|
|
>
|
|
<input
|
|
type="text"
|
|
class="sd-url-input"
|
|
:value="publishedUrl || pageUrl"
|
|
readonly
|
|
@click="$event.target.select()"
|
|
/>
|
|
<button
|
|
class="sd-btn-primary"
|
|
style="
|
|
height: 32px;
|
|
padding: 0 12px;
|
|
font-size: 12px;
|
|
"
|
|
@click="copyPageLink"
|
|
>
|
|
Copy link
|
|
</button>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<!-- Settings list -->
|
|
<div class="sd-settings">
|
|
<div class="sd-setting-row">
|
|
<span>🔗 Link expires</span>
|
|
<span style="color: var(--text-dim)"
|
|
>Never ▾</span
|
|
>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>🔍 Search engine indexing</span>
|
|
<span style="color: var(--text-dim)"
|
|
>Off ▾</span
|
|
>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>📄 Allow duplicate as template</span>
|
|
<label class="toggle-switch sm">
|
|
<input type="checkbox" checked />
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>✏️ Allow editing</span>
|
|
<label class="toggle-switch sm">
|
|
<input
|
|
type="checkbox"
|
|
x-model="pubAllowEdit"
|
|
/>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>💬 Allow comments</span>
|
|
<label class="toggle-switch sm">
|
|
<input
|
|
type="checkbox"
|
|
x-model="pubAllowComments"
|
|
checked
|
|
/>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<button
|
|
class="sd-danger-btn"
|
|
@click="unpublishPage()"
|
|
>
|
|
Unpublish
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- More menu dropdown -->
|
|
<div
|
|
class="more-menu"
|
|
x-show="moreOpen"
|
|
@click.outside="moreOpen = false"
|
|
x-transition
|
|
>
|
|
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
|
<div class="more-menu-item" @click="duplicatePage">
|
|
📋 Duplicate
|
|
</div>
|
|
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
|
<div class="more-menu-sep"></div>
|
|
<div class="more-menu-item danger" @click="deletePage">
|
|
🗑️ Move to Trash
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ Page Content ═══════════ -->
|
|
<div class="page-cover-area">
|
|
<div class="page-title-block">
|
|
<span class="page-icon-emoji">📄</span>
|
|
<div
|
|
class="page-title-input"
|
|
contenteditable="true"
|
|
id="_titleEl"
|
|
@input="dirty=true;save()"
|
|
@keydown.enter.prevent="focusBlock()"
|
|
@paste.prevent="pastePlain($event)"
|
|
spellcheck="false"
|
|
>
|
|
{{ page.title }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="blocks-container"
|
|
id="_blocksCt"
|
|
@click="onCtClick($event)"
|
|
></div>
|
|
<div
|
|
id="_slashMenu"
|
|
class="slash-menu"
|
|
style="display: none; position: fixed; z-index: 999"
|
|
></div>
|
|
|
|
<div
|
|
class="format-toolbar"
|
|
x-show="fmt.open"
|
|
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
|
@mousedown.prevent
|
|
>
|
|
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
|
<button @click="fmtApply('italic')"><em>I</em></button>
|
|
<button @click="fmtApply('underline')"><u>U</u></button>
|
|
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
|
<button @click="fmtLink()">🔗</button>
|
|
</div>
|
|
|
|
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
|
<div
|
|
class="sd-toast"
|
|
x-show="toastVisible"
|
|
x-transition
|
|
x-text="toastMsg"
|
|
></div>
|
|
|
|
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
|
<div
|
|
class="get-started-toolbar"
|
|
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
|
>
|
|
<span class="gs-label">Get started with</span>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
|
|
>
|
|
<span class="gs-icon">✨</span> Ask AI
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
|
|
>
|
|
<span class="gs-icon">📝</span> AI meeting note
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
|
|
>
|
|
<span class="gs-icon">🗄️</span> Database
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
|
|
>
|
|
<span class="gs-icon">📋</span> Form
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
|
|
>
|
|
<span class="gs-icon">📑</span> Templates
|
|
</button>
|
|
<div style="position: relative">
|
|
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
|
<span>⋯</span>
|
|
</button>
|
|
<div
|
|
class="gs-more-dropdown"
|
|
x-show="gsMoreOpen"
|
|
@click.outside="gsMoreOpen = false"
|
|
x-transition
|
|
>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📊 Table
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📋 Board
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📝 List
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📅 Timeline
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
🗓️ Calendar
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
🖼️ Gallery
|
|
</div>
|
|
<div class="gs-more-sep"></div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📥 Import
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-statusbar">
|
|
<span x-show="saving">Saving...</span>
|
|
<span x-show="!saving&&dirty">Unsaved</span>
|
|
<span x-show="!saving&&!dirty&&lastSaved"
|
|
>Saved <span x-text="lastSaved"></span
|
|
></span>
|
|
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
|
</div>
|
|
</div>
|
|
{% endblock %} {% block scripts %}
|
|
<script type="application/json" id="page-data">
|
|
{{ page_data | tojson }}
|
|
</script>
|
|
<script>
|
|
const CMDS=[{name:'BASIC',items:[
|
|
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
|
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
|
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
|
{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
|
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼'}]}];
|
|
|
|
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
|
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
|
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
|
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,'<').replace(/>/g,'>');}
|
|
|
|
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...';
|
|
inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
|
|
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';
|
|
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;
|
|
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
|
|
},
|
|
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
|
|
const r=el.getBoundingClientRect();SM._e.style.display='block';
|
|
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
|
|
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
|
|
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
|
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
|
|
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
|
|
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
|
|
h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
|
|
items.forEach(c=>{SM._it.push(c);
|
|
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
|
|
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
|
<span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
|
|
SM._li.innerHTML=h;},
|
|
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
|
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
|
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
|
|
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
|
|
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
|
|
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
|
get isOpen(){return SM._o;}};
|
|
|
|
function renderBlock(b,idx){
|
|
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></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...':'Press \'/\' for commands...';
|
|
let inner='';
|
|
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
|
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
|
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
|
|
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
|
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
|
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
|
|
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
|
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
|
|
return `<div class="block-wrapper">
|
|
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
|
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
|
|
<${tag}${style} class="${cls}">${inner}</${tag}>
|
|
</div>`;
|
|
}
|
|
|
|
window.E=null;
|
|
function editorState(){
|
|
return {
|
|
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
|
fmt:{open:false,top:0,left:0,idx:-1},
|
|
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
|
|
shareTab:'share',favorited:false,
|
|
pagePublished:{{ page_published | tojson }},
|
|
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
|
pubAllowEdit:false,pubAllowComments:true,
|
|
pageUrl:window.location.href,publishedUrl:'',
|
|
inviteEmail:'',invitePermission:'editor',accessList:[],
|
|
toastVisible:false,toastMsg:'',
|
|
init(){
|
|
_bid=Math.floor(Math.random()*10000);
|
|
const dataEl=document.getElementById('page-data');
|
|
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';const raw=data.content||'';
|
|
if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
|
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
|
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
|
window.E=this;SM.init();this.render();
|
|
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
|
ct.addEventListener('keydown',e=>this.onKd(e));
|
|
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
|
|
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
|
|
},100);
|
|
},
|
|
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;return b;},
|
|
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
|
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
|
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
|
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(){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<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
|
|
ct.querySelectorAll('.block-wrapper').forEach(w=>{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');};});
|
|
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/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);},
|
|
|
|
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(()=>{});
|
|
},
|
|
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
|
|
saveShare(){
|
|
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
|
|
},
|
|
copyPageLink(){
|
|
const url = this.pageUrl;
|
|
const fallback = () => {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = url; ta.style.position='fixed'; ta.style.opacity='0';
|
|
document.body.appendChild(ta); ta.select();
|
|
document.execCommand('copy'); document.body.removeChild(ta);
|
|
};
|
|
try {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
this.showToast('Link copied to clipboard');
|
|
}).catch(() => { fallback(); this.showToast('Link copied to clipboard'); });
|
|
} catch(e) { fallback(); this.showToast('Link copied to clipboard'); }
|
|
},
|
|
showToast(msg){
|
|
this.toastMsg = msg; this.toastVisible = true;
|
|
clearTimeout(this._toastTimer);
|
|
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
|
|
},
|
|
publishPage() {
|
|
var self = this;
|
|
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch('/api/pages/' + this.pid + '/publish', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
|
}).then(function(r){ return r.json(); }).then(function(d){
|
|
if (d.status === 'ok') { self.pagePublished = true; self.publishedUrl = d.url || self.publishedUrl; self.showToast('Published to web'); }
|
|
else { self.showToast(d.detail || 'Publish failed'); }
|
|
}).catch(function(){ self.showToast('Publish failed'); });
|
|
},
|
|
unpublishPage() {
|
|
var self = this;
|
|
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch('/api/pages/' + this.pid + '/publish', {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
|
}).then(function(r){ return r.json(); }).then(function(d){
|
|
if (d.status === 'ok') { self.pagePublished = false; self.publishedUrl = ''; self.showToast('Unpublished'); }
|
|
else { self.showToast(d.detail || 'Unpublish failed'); }
|
|
}).catch(function(){ self.showToast('Unpublish failed'); });
|
|
},
|
|
shareInvite() {
|
|
var self = this;
|
|
if (!this.inviteEmail.trim()) return;
|
|
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch('/api/pages/' + this.pid + '/share', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
|
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
|
|
}).then(function(r){ return r.json(); }).then(function(d){
|
|
if (d.status === 'ok') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
|
|
else { self.showToast(d.detail || 'Share failed'); }
|
|
}).catch(function(){ self.showToast('Share failed'); });
|
|
},
|
|
loadShares() {
|
|
var self = this;
|
|
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
|
|
self.accessList = d.shares || [];
|
|
}).catch(function(){ self.accessList = []; });
|
|
},
|
|
removeShare(sid) {
|
|
var self = this;
|
|
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch('/api/pages/' + this.pid + '/share/' + sid, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
|
}).then(function(r){ return r.json(); }).then(function(d){
|
|
if (d.status === 'ok') { self.showToast('Share removed'); self.loadShares(); }
|
|
else { self.showToast(d.detail || 'Remove failed'); }
|
|
}).catch(function(){ self.showToast('Remove failed'); });
|
|
},
|
|
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
|
|
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
|
|
exportPage(){this.moreOpen=false;},
|
|
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(()=>{window.showToast('Duplicate failed','error');});},
|
|
movePage(){this.moreOpen=false;},
|
|
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(()=>{window.showToast('Failed','error');});},
|
|
|
|
onKd(e){
|
|
const ab=this.getActiveBlock();if(!ab)return;
|
|
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
|
if(SM.isOpen){
|
|
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
|
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
|
|
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
|
return;
|
|
}
|
|
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);}
|
|
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
|
|
if(e.key==='Enter'){
|
|
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
|
e.preventDefault();
|
|
const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
|
|
if(!before.trim()&&!after.trim()){
|
|
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
|
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
|
|
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
|
|
this.dirty=true;this.autoSave();return;
|
|
}
|
|
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
|
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
|
|
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
|
|
this.dirty=true;this.autoSave();return;
|
|
}
|
|
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<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
|
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
|
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
|
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();
|
|
},
|
|
checkMd(idx){
|
|
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
|
|
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
|
|
this.sync();
|
|
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
|
|
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
|
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
|
},
|
|
applySlash(id){
|
|
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
|
const block=this.blocks[idx];this.sync();block.content='';
|
|
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
|
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
|
|
this.render();this.dirty=true;this.autoSave();
|
|
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
|
|
},
|
|
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
|
|
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
|
|
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
|
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
|
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
|
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
|
|
autoSave(){clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
|
save(){
|
|
if(this.saving)return;
|
|
this.sync();
|
|
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
|
|
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'].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';});
|
|
}).catch(()=>{this.saving=false;});
|
|
},
|
|
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
|
|
};
|
|
}
|
|
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
|
|
</script>
|
|
{% endblock %}
|