peek panels: reusable editor module — shared _page_editor_content.html + embed template
Architecture for zero code duplication:
- Extract page editor HTML into _page_editor_content.html (shared include)
- page_editor.html includes it (same behavior as before)
- page_editor_embed.html includes it with empty topbar (no header in peek)
- board.py: ?embed=1 renders page_editor_embed.html
Peek panels now load /pages/{id}?embed=1 in iframe:
- Editor renders without sidebar or header breadcrumb/actions
- Full editing capability (blocks, markdown, slash commands, save)
- Same code — one change to _page_editor_content.html updates everywhere
This commit is contained in:
@@ -946,7 +946,9 @@ async def delete_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||||
async def view_page(request: Request, page_id: int):
|
async def view_page(request: Request, page_id: int):
|
||||||
"""Render a page as HTML, or a file viewer for uploaded files."""
|
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||||
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||||
|
embed = request.query_params.get("embed") == "1"
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -1004,7 +1006,7 @@ async def view_page(request: Request, page_id: int):
|
|||||||
"breadcrumb_items": nav_crumbs,
|
"breadcrumb_items": nav_crumbs,
|
||||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||||
"nav_page_id": page_id}
|
"nav_page_id": page_id}
|
||||||
template = env.get_template("page_editor.html")
|
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||||
response = template.render(**ctx)
|
response = template.render(**ctx)
|
||||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
<div class="page-editor-wrapper" x-data="editorState()">
|
||||||
|
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
||||||
|
|
||||||
|
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||||
|
<div
|
||||||
|
class="share-dialog"
|
||||||
|
x-show="shareOpen"
|
||||||
|
@click.outside="!_justOpened && (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>{{ fd_icon('lock',14) }} 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>{{ fd_icon('globe',14) }} 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">
|
||||||
|
{{ fd_icon("link",14) }} 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">
|
||||||
|
{{ fd_icon('globe',14) }} {{ 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>{{ fd_icon("link",14) }} Link expires</span>
|
||||||
|
<span style="color: var(--text-dim)"
|
||||||
|
>Never ▾</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="sd-setting-row">
|
||||||
|
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
|
||||||
|
<span style="color: var(--text-dim)"
|
||||||
|
>Off ▾</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="sd-setting-row">
|
||||||
|
<span>{{ fd_icon("file",14) }} 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>{{ fd_icon("edit",14) }} 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>{{ fd_icon("message-square",14) }} 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>
|
||||||
|
|
||||||
|
<!-- ═══════════ Activity Popover ═══════════ -->
|
||||||
|
<div class="activity-popover"
|
||||||
|
x-show="activityOpen"
|
||||||
|
@click.outside="!_justOpened && (activityOpen = 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:280px;padding:12px;">
|
||||||
|
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
|
||||||
|
<template x-if="pageCreated">
|
||||||
|
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- More menu dropdown -->
|
||||||
|
<div
|
||||||
|
class="more-menu"
|
||||||
|
x-show="moreOpen"
|
||||||
|
@click.outside="!_justOpened && (moreOpen = false)"
|
||||||
|
x-transition
|
||||||
|
>
|
||||||
|
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
||||||
|
<div class="more-menu-item" @click="duplicatePage">
|
||||||
|
{{ fd_icon("copy",14) }} 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">
|
||||||
|
{{ fd_icon("trash",14) }} Move to Trash
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ Move to Dialog ═══════════ -->
|
||||||
|
<div class="move-dialog"
|
||||||
|
x-show="moveOpen"
|
||||||
|
@click.outside="moveOpen = false"
|
||||||
|
x-transition.opacity.scale
|
||||||
|
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:320px;padding:16px;">
|
||||||
|
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
|
||||||
|
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
|
||||||
|
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
|
||||||
|
<template x-for="ws in moveWorkspaces" :key="ws.id">
|
||||||
|
<div class="move-ws-item"
|
||||||
|
@click="doMove(ws.id)"
|
||||||
|
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
|
||||||
|
<span>{{ fd_icon("folder",14) }}</span>
|
||||||
|
<span x-text="ws.name" style="flex:1;"></span>
|
||||||
|
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
|
||||||
|
No workspaces available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
@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()">{{ fd_icon("link",14) }}</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">{{ fd_icon("sparkles",14) }}</span> Ask AI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="gs-pill"
|
||||||
|
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
|
||||||
|
>
|
||||||
|
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="gs-pill"
|
||||||
|
@click="createDatabase()"
|
||||||
|
>
|
||||||
|
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="gs-pill"
|
||||||
|
@click="createForm()"
|
||||||
|
>
|
||||||
|
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="gs-pill"
|
||||||
|
@click="gsMoreOpen = !gsMoreOpen"
|
||||||
|
>
|
||||||
|
<span class="gs-icon">{{ fd_icon("file",14) }}</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">
|
||||||
|
{{ fd_icon("bar-chart",14) }} Table
|
||||||
|
</div>
|
||||||
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||||
|
{{ fd_icon("copy",14) }} Board
|
||||||
|
</div>
|
||||||
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||||
|
{{ fd_icon('list',14) }} List
|
||||||
|
</div>
|
||||||
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||||
|
{{ fd_icon("calendar",14) }} Timeline
|
||||||
|
</div>
|
||||||
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||||
|
{{ fd_icon("calendar",14) }} Calendar
|
||||||
|
</div>
|
||||||
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||||
|
{{ fd_icon("image",14) }} Gallery
|
||||||
|
</div>
|
||||||
|
<div class="gs-more-sep"></div>
|
||||||
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||||
|
{{ fd_icon("download",14) }} 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>
|
||||||
@@ -446,13 +446,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="peek-body">
|
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
|
||||||
<div x-show="peekLoading" class="peek-loading">Loading…</div>
|
<iframe id="lib-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||||
<div x-show="!peekLoading && peekContent" x-html="peekContent"></div>
|
|
||||||
<div x-show="!peekLoading && !peekContent" class="peek-empty">
|
|
||||||
<div style="margin-bottom:8px;">{{ fd_icon("file",32) }}</div>
|
|
||||||
<div style="font-size:14px;color:var(--text-secondary);">No preview available</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1203,40 +1198,15 @@ function libraryPage() {
|
|||||||
// ── Side peek ──
|
// ── Side peek ──
|
||||||
async openPeek(item) {
|
async openPeek(item) {
|
||||||
this.peekItem = item;
|
this.peekItem = item;
|
||||||
this.peekContent = '';
|
|
||||||
this.peekLoading = true;
|
|
||||||
this.peekFavorited = item.favorited || false;
|
this.peekFavorited = item.favorited || false;
|
||||||
try {
|
var iframe = document.getElementById('lib-peek-iframe');
|
||||||
var r = await fetch('/board/api/pages/' + item.id);
|
if (iframe) iframe.src = '/pages/' + item.id + '?embed=1';
|
||||||
var d = await r.json();
|
|
||||||
var content = d.content || '';
|
|
||||||
// Simple preview
|
|
||||||
if (d.content_format === 'blocks') {
|
|
||||||
try {
|
|
||||||
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
|
|
||||||
var preview = '';
|
|
||||||
var lines = Array.isArray(blocks) ? blocks.slice(0, 30) : ((blocks && blocks.blocks) ? blocks.blocks.slice(0, 30) : []);
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var b = lines[i];
|
|
||||||
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
|
|
||||||
if (txt && txt.trim()) preview += '<div style="margin-bottom:6px;font-size:14px;line-height:1.5;color:var(--text-primary);">' + this._escHtml(String(txt)) + '</div>';
|
|
||||||
}
|
|
||||||
this.peekContent = preview || '<div style="color:var(--text-dim);">(empty page)</div>';
|
|
||||||
} catch(e) {
|
|
||||||
this.peekContent = '<pre style="font-size:13px;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.peekContent = content ? '<div style="font-size:14px;line-height:1.6;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</div>' : '';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
this.peekContent = '';
|
|
||||||
}
|
|
||||||
this.peekLoading = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
closePeek() {
|
closePeek() {
|
||||||
this.peekItem = null;
|
this.peekItem = null;
|
||||||
this.peekContent = '';
|
var iframe = document.getElementById('lib-peek-iframe');
|
||||||
|
if (iframe) iframe.src = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleFavoritePeek() {
|
async toggleFavoritePeek() {
|
||||||
|
|||||||
@@ -831,20 +831,16 @@ window._wsInitData = (function() {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Preview panel (vanilla JS — matches Library peek-overlay) ──
|
// ── Preview panel (loads editor in iframe via /pages/{id}?embed=1) ──
|
||||||
selectForPreview(node) {
|
selectForPreview(node) {
|
||||||
var panel = document.getElementById('ws-preview-panel');
|
var panel = document.getElementById('ws-preview-panel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
// Icon + title
|
|
||||||
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format);
|
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format);
|
||||||
document.getElementById('ws-preview-title').textContent = node.name;
|
document.getElementById('ws-preview-title').textContent = node.name;
|
||||||
// Open button → navigate to /pages/{id}
|
|
||||||
var openBtn = document.getElementById('ws-preview-open-btn');
|
var openBtn = document.getElementById('ws-preview-open-btn');
|
||||||
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
|
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
|
||||||
// Favorite button
|
|
||||||
var favBtn = document.getElementById('ws-preview-fav-btn');
|
var favBtn = document.getElementById('ws-preview-fav-btn');
|
||||||
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
|
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
|
||||||
// Copy link button
|
|
||||||
var copyBtn = document.getElementById('ws-preview-copy-btn');
|
var copyBtn = document.getElementById('ws-preview-copy-btn');
|
||||||
copyBtn.onclick = function() {
|
copyBtn.onclick = function() {
|
||||||
var url = window.location.origin + '/pages/' + node.id;
|
var url = window.location.origin + '/pages/' + node.id;
|
||||||
@@ -854,30 +850,16 @@ window._wsInitData = (function() {
|
|||||||
window.showToast('Failed to copy', 'error');
|
window.showToast('Failed to copy', 'error');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Body: meta info header + loading indicator
|
// Load editor in iframe
|
||||||
var meta = '<div style="display:flex;gap:16px;flex-wrap:wrap;padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--text-tertiary);">'
|
var iframe = document.getElementById('ws-preview-iframe');
|
||||||
+ '<span>' + (node.is_folder ? 'Folder' : this._fileTypeLabel(node.name, node.content_format)) + '</span>'
|
if (iframe) iframe.src = '/pages/' + node.id + '?embed=1';
|
||||||
+ '<span>' + (node.size_display || '-') + '</span>'
|
// Show panel
|
||||||
+ '<span>' + this._formatDate(node.updated_at) + '</span>'
|
|
||||||
+ '</div>';
|
|
||||||
if (node.tags && node.tags.length > 0) {
|
|
||||||
meta += '<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px;">';
|
|
||||||
for (var i = 0; i < node.tags.length; i++) {
|
|
||||||
var t = node.tags[i];
|
|
||||||
meta += '<span class="tag-chip" style="background:' + this._escHtml(t.color) + '">' + this._escHtml(t.name) + '</span>';
|
|
||||||
}
|
|
||||||
meta += '</div>';
|
|
||||||
}
|
|
||||||
document.getElementById('ws-preview-body').innerHTML = meta + '<div style="text-align:center;padding:40px;color:var(--text-tertiary)">Loading...</div>';
|
|
||||||
// Show panel with slide-in
|
|
||||||
panel.style.display = 'flex';
|
panel.style.display = 'flex';
|
||||||
requestAnimationFrame(function() {
|
requestAnimationFrame(function() {
|
||||||
requestAnimationFrame(function() {
|
requestAnimationFrame(function() {
|
||||||
panel.style.transform = 'translateX(0)';
|
panel.style.transform = 'translateX(0)';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Load content async
|
|
||||||
this._loadPreviewContentVanilla(node);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
closePreviewPanel() {
|
closePreviewPanel() {
|
||||||
@@ -2949,9 +2931,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="ws-preview-body" style="flex:1;overflow-y:auto;padding:16px 20px;">
|
<iframe id="ws-preview-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||||
<div style="text-align:center;padding:60px 20px;color:var(--text-dim);">{{ fd_icon("file",32) }}<div style="margin-top:8px;font-size:14px;">Select a file to preview</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /ws-split -->
|
</div><!-- /ws-split -->
|
||||||
|
|
||||||
|
|||||||
@@ -5,519 +5,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
<div class="page-editor-wrapper" x-data="editorState()">
|
{% include '_page_editor_content.html' %}
|
||||||
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
|
||||||
|
|
||||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
|
||||||
<div
|
|
||||||
class="share-dialog"
|
|
||||||
x-show="shareOpen"
|
|
||||||
@click.outside="!_justOpened && (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>{{ fd_icon('lock',14) }} 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>{{ fd_icon('globe',14) }} 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">
|
|
||||||
{{ fd_icon("link",14) }} 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">
|
|
||||||
{{ fd_icon('globe',14) }} {{ 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>{{ fd_icon("link",14) }} Link expires</span>
|
|
||||||
<span style="color: var(--text-dim)"
|
|
||||||
>Never ▾</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="sd-setting-row">
|
|
||||||
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
|
|
||||||
<span style="color: var(--text-dim)"
|
|
||||||
>Off ▾</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="sd-setting-row">
|
|
||||||
<span>{{ fd_icon("file",14) }} 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>{{ fd_icon("edit",14) }} 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>{{ fd_icon("message-square",14) }} 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>
|
|
||||||
|
|
||||||
<!-- ═══════════ Activity Popover ═══════════ -->
|
|
||||||
<div class="activity-popover"
|
|
||||||
x-show="activityOpen"
|
|
||||||
@click.outside="!_justOpened && (activityOpen = 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:280px;padding:12px;">
|
|
||||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
|
|
||||||
<template x-if="pageCreated">
|
|
||||||
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- More menu dropdown -->
|
|
||||||
<div
|
|
||||||
class="more-menu"
|
|
||||||
x-show="moreOpen"
|
|
||||||
@click.outside="!_justOpened && (moreOpen = false)"
|
|
||||||
x-transition
|
|
||||||
>
|
|
||||||
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
|
||||||
<div class="more-menu-item" @click="duplicatePage">
|
|
||||||
{{ fd_icon("copy",14) }} 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">
|
|
||||||
{{ fd_icon("trash",14) }} Move to Trash
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══════════ Move to Dialog ═══════════ -->
|
|
||||||
<div class="move-dialog"
|
|
||||||
x-show="moveOpen"
|
|
||||||
@click.outside="moveOpen = false"
|
|
||||||
x-transition.opacity.scale
|
|
||||||
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:320px;padding:16px;">
|
|
||||||
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
|
|
||||||
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
|
|
||||||
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
|
|
||||||
<template x-for="ws in moveWorkspaces" :key="ws.id">
|
|
||||||
<div class="move-ws-item"
|
|
||||||
@click="doMove(ws.id)"
|
|
||||||
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
|
|
||||||
<span>{{ fd_icon("folder",14) }}</span>
|
|
||||||
<span x-text="ws.name" style="flex:1;"></span>
|
|
||||||
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
|
|
||||||
No workspaces available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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"
|
|
||||||
@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()">{{ fd_icon("link",14) }}</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">{{ fd_icon("sparkles",14) }}</span> Ask AI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="gs-pill"
|
|
||||||
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
|
|
||||||
>
|
|
||||||
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="gs-pill"
|
|
||||||
@click="createDatabase()"
|
|
||||||
>
|
|
||||||
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="gs-pill"
|
|
||||||
@click="createForm()"
|
|
||||||
>
|
|
||||||
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="gs-pill"
|
|
||||||
@click="gsMoreOpen = !gsMoreOpen"
|
|
||||||
>
|
|
||||||
<span class="gs-icon">{{ fd_icon("file",14) }}</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">
|
|
||||||
{{ fd_icon("bar-chart",14) }} Table
|
|
||||||
</div>
|
|
||||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
||||||
{{ fd_icon("copy",14) }} Board
|
|
||||||
</div>
|
|
||||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
||||||
{{ fd_icon('list',14) }} List
|
|
||||||
</div>
|
|
||||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
||||||
{{ fd_icon("calendar",14) }} Timeline
|
|
||||||
</div>
|
|
||||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
||||||
{{ fd_icon("calendar",14) }} Calendar
|
|
||||||
</div>
|
|
||||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
||||||
{{ fd_icon("image",14) }} Gallery
|
|
||||||
</div>
|
|
||||||
<div class="gs-more-sep"></div>
|
|
||||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
||||||
{{ fd_icon("download",14) }} 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 %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="application/json" id="page-data">
|
<script type="application/json" id="page-data">
|
||||||
{{ page_data | tojson }}
|
{{ page_data | tojson }}
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}{{ page.title }}{% endblock %}
|
||||||
|
{# No topbar in embed mode — just the editor #}
|
||||||
|
{% block topbar %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include '_page_editor_content.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="application/json" id="page-data">
|
||||||
|
{{ page_data | tojson }}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
/* eslint-disable */
|
||||||
|
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 list',icon:'☐'},{id:'toggle',name:'Toggle list',icon:'▶'},{id:'code',name:'Code',icon:'</>'},
|
||||||
|
{id:'quote',name:'Quote',icon:'❝'},{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},
|
||||||
|
{id:'image',name:'Image',icon:'🖼'},{id:'video',name:'Video',icon:'▶'},{id:'audio',name:'Audio',icon:'♪'},
|
||||||
|
{id:'file',name:'File',icon:'📎'},{id:'bookmark',name:'Web bookmark',icon:'🔖'}]},
|
||||||
|
{name:'DATABASE',items:[{id:'database',name:'Database - Inline',icon:'🗄'},{id:'table',name:'Table view',icon:'📊'},
|
||||||
|
{id:'board',name:'Board view',icon:'📋'},{id:'list',name:'List view',icon:'📝'},{id:'timeline',name:'Timeline view',icon:'📅'},
|
||||||
|
{id:'calendar',name:'Calendar view',icon:'📆'},{id:'gallery',name:'Gallery view',icon:'🖼'}]},
|
||||||
|
{name:'ADVANCED',items:[{id:'table_of_contents',name:'Table of contents',icon:'📑'},{id:'math',name:'Math equation',icon:'∑'},
|
||||||
|
{id:'button',name:'Button',icon:'🔘'},{id:'breadcrumb',name:'Breadcrumb',icon:'»'},
|
||||||
|
{id:'synced_block',name:'Synced block',icon:'🔄'}]},
|
||||||
|
{name:'EMBEDS',items:[{id:'pdf',name:'PDF',icon:'📄'},{id:'google_drive',name:'Google Drive',icon:'📁'},
|
||||||
|
{id:'google_maps',name:'Google Maps',icon:'🗺'},{id:'figma',name:'Figma',icon:'🎨'},{id:'typeform',name:'Typeform',icon:'📝'},
|
||||||
|
{id:'codepen',name:'CodePen',icon:'✒'},{id:'excalidraw',name:'Excalidraw',icon:'✏'}]},
|
||||||
|
{name:'AI',items:[{id:'ask_ai',name:'Ask AI',icon:'✨'},{id:'ai_summary',name:'AI Summary',icon:'📋'},
|
||||||
|
{id:'ai_translate',name:'AI Translate',icon:'🌐'}]}];
|
||||||
|
|
||||||
|
// ── Editor State ──
|
||||||
|
function editorState() {
|
||||||
|
return {
|
||||||
|
// Core state
|
||||||
|
pid: 1,
|
||||||
|
pageTitle: '',
|
||||||
|
favorited: false,
|
||||||
|
shareMode: 'private',
|
||||||
|
pagePublished: false,
|
||||||
|
pageCreated: null,
|
||||||
|
pageUrl: '',
|
||||||
|
blocks: [],
|
||||||
|
draftBlocks: null,
|
||||||
|
blockRefs: new Map(),
|
||||||
|
blockCache: new Map(),
|
||||||
|
blockRendered: new Set(),
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
shareOpen: false,
|
||||||
|
shareTab: 'share',
|
||||||
|
activityOpen: false,
|
||||||
|
moreOpen: false,
|
||||||
|
moveOpen: false,
|
||||||
|
moveLoading: false,
|
||||||
|
moveWorkspaces: [],
|
||||||
|
gsMoreOpen: false,
|
||||||
|
|
||||||
|
// Share state
|
||||||
|
inviteEmail: '',
|
||||||
|
accessList: [],
|
||||||
|
generalAccess: 'invited',
|
||||||
|
accessMenuOpen: false,
|
||||||
|
publicPerm: 'viewer',
|
||||||
|
pubAllowEdit: false,
|
||||||
|
pubAllowComments: true,
|
||||||
|
publishedUrl: '',
|
||||||
|
|
||||||
|
// Format toolbar
|
||||||
|
fmt: {open:false, top:0, left:0, sel:null},
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
toastVisible: false,
|
||||||
|
toastMsg: '',
|
||||||
|
|
||||||
|
// Save status
|
||||||
|
saving: false,
|
||||||
|
dirty: false,
|
||||||
|
lastSaved: '',
|
||||||
|
timeAgo: '',
|
||||||
|
saveTimer: null,
|
||||||
|
|
||||||
|
// Anti-bounce for click.outside
|
||||||
|
_justOpened: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
var dataEl = document.getElementById('page-data');
|
||||||
|
if (dataEl) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(dataEl.textContent);
|
||||||
|
this.pid = data.id;
|
||||||
|
this.pageTitle = data.title || '';
|
||||||
|
this.favorited = data.favorited || false;
|
||||||
|
var fmt = data.content_format || 'blocks';
|
||||||
|
var raw = data.content || '';
|
||||||
|
if (fmt === 'blocks') {
|
||||||
|
try { this.blocks = JSON.parse(raw) || []; } catch(e) { this.blocks = []; }
|
||||||
|
} else {
|
||||||
|
this.blocks = [{id: 'b0', type: 'paragraph', content: raw || ''}];
|
||||||
|
}
|
||||||
|
this.pagePublished = data.published || false;
|
||||||
|
this.pageCreated = data.created_at || null;
|
||||||
|
this.pageUrl = window.location.origin + '/pages/' + this.pid;
|
||||||
|
if (data.update_time) {
|
||||||
|
this.timeAgo = this._timeAgo(data.update_time);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
if (!this.blocks || this.blocks.length === 0) {
|
||||||
|
this.blocks = [{id: 'b0', type: 'paragraph', content: ''}];
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
window.E = this;
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var ct = document.getElementById('_blocksCt');
|
||||||
|
if (!ct) return;
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < this.blocks.length; i++) {
|
||||||
|
html += this._renderBlock(this.blocks[i], i);
|
||||||
|
}
|
||||||
|
ct.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderBlock(b, idx) {
|
||||||
|
var txt = (b.content || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
var type = b.type || 'paragraph';
|
||||||
|
var tag = 'div';
|
||||||
|
var cls = 'block';
|
||||||
|
if (type.indexOf('heading') === 0) {
|
||||||
|
var lvl = parseInt(type.split('_')[1]) || 1;
|
||||||
|
tag = 'h' + Math.min(lvl + 2, 6);
|
||||||
|
cls += ' block-heading';
|
||||||
|
}
|
||||||
|
if (type === 'code') { tag = 'pre'; cls += ' block-code'; txt = '<code>' + txt + '</code>'; }
|
||||||
|
if (type === 'quote') { tag = 'blockquote'; cls += ' block-quote'; }
|
||||||
|
if (type === 'callout') { cls += ' block-callout'; }
|
||||||
|
if (type === 'to_do') {
|
||||||
|
var checked = b.checked ? ' checked' : '';
|
||||||
|
return '<div class="' + cls + ' block-todo" data-idx="' + idx + '"><input type="checkbox"' + checked + ' onchange="window.E.toggleTodo(' + idx + ',this.checked)"><div class="block-content" contenteditable="true" oninput="window.E.onBlockInput(' + idx + ',this)" onkeydown="return window.E.onBlockKey(' + idx + ',event)">' + (txt || '<br>') + '</div></div>';
|
||||||
|
}
|
||||||
|
return '<' + tag + ' class="' + cls + '" data-idx="' + idx + '"><div class="block-content" contenteditable="true" oninput="window.E.onBlockInput(' + idx + ',this)" onkeydown="return window.E.onBlockKey(' + idx + ',event)">' + (txt || '<br>') + '</div></' + tag + '>';
|
||||||
|
},
|
||||||
|
|
||||||
|
focusBlock(idx) {
|
||||||
|
if (typeof idx === 'undefined') idx = 0;
|
||||||
|
setTimeout(function() {
|
||||||
|
var el = document.querySelector('[data-idx="' + idx + '"] .block-content');
|
||||||
|
if (el) { el.focus(); }
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
|
||||||
|
onBlockInput(idx, el) {
|
||||||
|
this.dirty = true;
|
||||||
|
this.blocks[idx].content = el.innerText;
|
||||||
|
this.debounceSave();
|
||||||
|
},
|
||||||
|
|
||||||
|
onBlockKey(idx, e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
var text = e.target.innerText;
|
||||||
|
this.blocks[idx].content = text;
|
||||||
|
var newBlock = {id: 'b' + Date.now(), type: 'paragraph', content: ''};
|
||||||
|
this.blocks.splice(idx + 1, 0, newBlock);
|
||||||
|
this.render();
|
||||||
|
this.focusBlock(idx + 1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && e.target.innerText === '' && this.blocks.length > 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.blocks.splice(idx, 1);
|
||||||
|
this.render();
|
||||||
|
this.focusBlock(idx - 1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.key === '/') {
|
||||||
|
var s = window.getSelection();
|
||||||
|
if (s.rangeCount && s.getRangeAt(0).startOffset === 0 && e.target.innerText === '/') {
|
||||||
|
this._showSlash(idx, e.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
_showSlash(idx, el) {
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var menu = document.getElementById('_slashMenu');
|
||||||
|
if (!menu) return;
|
||||||
|
var html = '<div style="padding:4px;min-width:200px;">';
|
||||||
|
for (var g = 0; g < CMDS.length; g++) {
|
||||||
|
var group = CMDS[g];
|
||||||
|
html += '<div style="padding:4px 8px;font-size:11px;color:var(--text-dim);text-transform:uppercase;">' + group.name + '</div>';
|
||||||
|
for (var j = 0; j < group.items.length; j++) {
|
||||||
|
var item = group.items[j];
|
||||||
|
html += '<div class="slash-item" onclick="window.E._applySlash(' + idx + ',\'' + item.id + '\')" style="padding:6px 10px;cursor:pointer;display:flex;align-items:center;gap:10px;">' +
|
||||||
|
'<span style="font-size:16px;">' + item.icon + '</span>' +
|
||||||
|
'<span style="font-size:13px;">' + item.name + '</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
menu.innerHTML = html;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
menu.style.top = (rect.bottom + 4) + 'px';
|
||||||
|
menu.style.left = rect.left + 'px';
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function() {
|
||||||
|
document.addEventListener('click', function handler(e) {
|
||||||
|
if (!menu.contains(e.target)) {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
_applySlash(idx, type) {
|
||||||
|
this.blocks[idx].type = type;
|
||||||
|
this.blocks[idx].content = '';
|
||||||
|
this.render();
|
||||||
|
var menu = document.getElementById('_slashMenu');
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
this.focusBlock(idx);
|
||||||
|
this.dirty = true;
|
||||||
|
this.debounceSave();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTodo(idx, checked) {
|
||||||
|
this.blocks[idx].checked = checked;
|
||||||
|
this.dirty = true;
|
||||||
|
this.debounceSave();
|
||||||
|
},
|
||||||
|
|
||||||
|
debounceSave() {
|
||||||
|
var self = this;
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = setTimeout(function() { self.save(); }, 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
if (this.saving) return;
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
var csrf = this._getCsrf();
|
||||||
|
var body = {title: this.pageTitle, content: JSON.stringify(this.blocks), content_format: 'blocks'};
|
||||||
|
var r = await fetch('/board/api/pages/' + this.pid, {
|
||||||
|
method: 'PUT', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
this.dirty = false;
|
||||||
|
var now = new Date();
|
||||||
|
this.lastSaved = now.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
this.saving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
pastePlain(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
onCtClick(e) {
|
||||||
|
var ct = document.getElementById('_blocksCt');
|
||||||
|
if (e.target === ct || (e.target.classList && e.target.classList.contains('blocks-container'))) {
|
||||||
|
var newBlock = {id: 'b' + Date.now(), type: 'paragraph', content: ''};
|
||||||
|
this.blocks.push(newBlock);
|
||||||
|
this.render();
|
||||||
|
this.focusBlock(this.blocks.length - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtApply(cmd) { document.execCommand(cmd); this.dirty = true; this.fmt.open = false; },
|
||||||
|
fmtLink() {
|
||||||
|
var url = prompt('Enter URL:');
|
||||||
|
if (url) { document.execCommand('createLink', false, url); this.dirty = true; }
|
||||||
|
this.fmt.open = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
replaceBlock(idx, type) { this.blocks[idx].type = type; },
|
||||||
|
createDatabase() {
|
||||||
|
var name = prompt('Database name:');
|
||||||
|
if (!name) return;
|
||||||
|
var self = this;
|
||||||
|
(async function() {
|
||||||
|
var csrf = self._getCsrf();
|
||||||
|
await fetch('/db/inline/api', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
||||||
|
body: JSON.stringify({page_id: self.pid, name: name})
|
||||||
|
});
|
||||||
|
window.showToast && window.showToast('Database created', 'success');
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
createForm() { window.showToast && window.showToast('Form created', 'success'); },
|
||||||
|
|
||||||
|
toggleFavorite() {
|
||||||
|
var self = this;
|
||||||
|
(async function() {
|
||||||
|
var csrf = self._getCsrf();
|
||||||
|
var method = self.favorited ? 'DELETE' : 'POST';
|
||||||
|
await fetch('/board/api/favorites/' + self.pid, {method: method, headers: {'X-CSRF-Token': csrf}});
|
||||||
|
self.favorited = !self.favorited;
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
copyPageLink() {
|
||||||
|
navigator.clipboard.writeText(this.pageUrl || window.location.href);
|
||||||
|
this.showToast('Link copied');
|
||||||
|
},
|
||||||
|
toggleShareOpen() { this.shareOpen = !this.shareOpen; if (this.shareOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
|
||||||
|
toggleActivityOpen() { this.activityOpen = !this.activityOpen; if (this.activityOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
|
||||||
|
toggleMoreOpen() { this.moreOpen = !this.moreOpen; if (this.moreOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
|
||||||
|
|
||||||
|
showToast(msg, type) {
|
||||||
|
this.toastMsg = msg;
|
||||||
|
this.toastVisible = true;
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function() { self.toastVisible = false; }, 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
exportPage() { window.showToast && window.showToast('Export started', 'success'); },
|
||||||
|
duplicatePage() {
|
||||||
|
var self = this;
|
||||||
|
(async function() {
|
||||||
|
var csrf = self._getCsrf();
|
||||||
|
var r = await fetch('/board/api/pages', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
||||||
|
body: JSON.stringify({title: self.pageTitle + ' (copy)', content: JSON.stringify(self.blocks), content_format: 'blocks'})
|
||||||
|
});
|
||||||
|
if (r.ok) { var d = await r.json(); window.location = '/pages/' + d.id; }
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
movePage() { this.moveOpen = true; this._loadWorkspaces(); },
|
||||||
|
async _loadWorkspaces() {
|
||||||
|
this.moveLoading = true;
|
||||||
|
try { var r = await fetch('/api/workspaces'); var d = await r.json(); this.moveWorkspaces = d.workspaces || []; } catch(e) {}
|
||||||
|
this.moveLoading = false;
|
||||||
|
},
|
||||||
|
async doMove(wsId) {
|
||||||
|
var csrf = this._getCsrf();
|
||||||
|
await fetch('/board/api/pages/' + this.pid + '/move', {
|
||||||
|
method: 'PUT', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
||||||
|
body: JSON.stringify({workspace_id: wsId})
|
||||||
|
});
|
||||||
|
this.moveOpen = false;
|
||||||
|
window.showToast && window.showToast('Moved', 'success');
|
||||||
|
},
|
||||||
|
deletePage() {
|
||||||
|
if (!confirm('Move to Trash?')) return;
|
||||||
|
var self = this;
|
||||||
|
(async function() {
|
||||||
|
var csrf = self._getCsrf();
|
||||||
|
await fetch('/board/api/pages/' + self.pid, {method: 'DELETE', headers: {'X-CSRF-Token': csrf}});
|
||||||
|
window.location = '/workspaces';
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
publishPage() {
|
||||||
|
var self = this;
|
||||||
|
(async function() {
|
||||||
|
var csrf = self._getCsrf();
|
||||||
|
await fetch('/pages/' + self.pid + '/publish', {method: 'POST', headers: {'X-CSRF-Token': csrf}});
|
||||||
|
self.pagePublished = true;
|
||||||
|
self.publishedUrl = window.location.origin + '/p/' + self.pid;
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
unpublishPage() {
|
||||||
|
var self = this;
|
||||||
|
(async function() {
|
||||||
|
var csrf = self._getCsrf();
|
||||||
|
await fetch('/pages/' + self.pid + '/publish', {method: 'DELETE', headers: {'X-CSRF-Token': csrf}});
|
||||||
|
self.pagePublished = false;
|
||||||
|
self.publishedUrl = '';
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
shareInvite() { if (this.inviteEmail) { this.accessList.push({email: this.inviteEmail, permission: 'editor', permOpen: false}); this.inviteEmail = ''; } },
|
||||||
|
removeAccess(email) { this.accessList = this.accessList.filter(function(a) { return a.email !== email; }); },
|
||||||
|
formatDate(d) { try { return new Date(d).toLocaleDateString(); } catch(e) { return d; } },
|
||||||
|
|
||||||
|
_getCsrf() {
|
||||||
|
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
_timeAgo(d) {
|
||||||
|
try {
|
||||||
|
var dt = new Date(d);
|
||||||
|
var now = new Date();
|
||||||
|
var diff = (now - dt) / 1000;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
var mins = Math.floor(diff/60);
|
||||||
|
if (mins < 60) return mins + 'm ago';
|
||||||
|
var hours = Math.floor(mins/60);
|
||||||
|
if (hours < 24) return hours + 'h ago';
|
||||||
|
var days = Math.floor(hours/24);
|
||||||
|
if (days < 7) return days + 'd ago';
|
||||||
|
if (days < 30) return Math.floor(days/7) + 'w ago';
|
||||||
|
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
||||||
|
} catch(e) { return d; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user