feat(collab): v4.9.0 Collaboration - commentaires inline, mentions @, notifications in-app + email
This commit is contained in:
@@ -136,6 +136,7 @@
|
||||
</div>
|
||||
|
||||
<div class="topbar-right header-actions">
|
||||
{% include '_notification_bell.html' %}
|
||||
{{ right_actions|safe if right_actions else '' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
{# ── Notification bell + dropdown (v4.9.0) ──
|
||||
Self-contained Alpine component. Polls unread count, opens a panel of
|
||||
notifications, marks as read. Only rendered for authenticated users. #}
|
||||
{% if user and user.get('id') %}
|
||||
<span class="topbar-btn fd-notif-bell" x-data="fdNotifications()" x-init="init()"
|
||||
@click.outside="open=false" style="position:relative;display:inline-flex;">
|
||||
<button type="button" class="topbar-btn" @click="toggle()" title="Notifications"
|
||||
style="padding:6px;position:relative;border:none;background:none;cursor:pointer;color:var(--text);">
|
||||
{{ fd_icon("bell", 16) }}
|
||||
<span x-show="unread > 0" x-cloak
|
||||
class="fd-notif-badge"
|
||||
x-text="unread > 99 ? '99+' : unread"
|
||||
style="position:absolute;top:0;right:0;background:#E03E3E;color:#fff;
|
||||
border-radius:10px;font-size:10px;line-height:1;padding:3px 5px;
|
||||
font-weight:700;min-width:16px;text-align:center;transform:translate(30%,-30%);"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak x-transition
|
||||
class="fd-notif-panel"
|
||||
style="position:absolute;top:calc(100% + 6px);right:0;width:340px;max-width:92vw;
|
||||
background:var(--bg-primary,#1f1f1f);border:1px solid var(--border,#333);
|
||||
border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden;z-index:2000;">
|
||||
<div class="fd-notif-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Notifications</span>
|
||||
<button type="button" class="btn-sm" @click="markAllRead()" x-show="unread > 0"
|
||||
style="font-size:12px;cursor:pointer;">Mark all read</button>
|
||||
</div>
|
||||
<div class="fd-notif-list" style="max-height:360px;overflow-y:auto;">
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<a :href="n.url || '#'" @click.prevent="openItem(n)"
|
||||
class="fd-notif-item"
|
||||
style="display:flex;gap:10px;padding:10px 14px;text-decoration:none;color:var(--text);
|
||||
border-bottom:1px solid var(--border,#2a2a2a);cursor:pointer;"
|
||||
:style="{ background: n.is_read ? 'transparent' : 'rgba(35,131,226,.10)' }">
|
||||
<div style="width:30px;height:30px;border-radius:50%;flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-weight:700;font-size:14px;color:#fff;"
|
||||
:style="{ background: n.actor_color || '#3A3A3A' }">
|
||||
<template x-if="n.actor_avatar">
|
||||
<img :src="n.actor_avatar" style="width:30px;height:30px;border-radius:50%;object-fit:cover;">
|
||||
</template>
|
||||
<span x-show="!n.actor_avatar" x-text="(n.actor_name || n.actor_login || '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text);" x-text="n.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);margin-top:2px;white-space:normal;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
|
||||
x-text="n.message"></div>
|
||||
<div style="font-size:11px;color:var(--text-tertiary,#777);margin-top:4px;" x-text="timeAgo(n.created_at)"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="!loading && items.length === 0"
|
||||
style="padding:24px;text-align:center;color:var(--text-dim,#999);font-size:13px;">
|
||||
You're all caught up 🎉
|
||||
</div>
|
||||
<div x-show="loading" style="padding:24px;text-align:center;color:var(--text-dim,#999);">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||
Alpine.data('fdNotifications', function () {
|
||||
return {
|
||||
open: false, items: [], unread: 0, loading: false, _timer: null,
|
||||
init() {
|
||||
this.load();
|
||||
var self = this;
|
||||
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
||||
},
|
||||
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
||||
timeAgo(s) {
|
||||
if (!s) return '';
|
||||
var t = new Date((String(s).includes('Z') || String(s).includes('T') ? s : s + 'Z'));
|
||||
if (isNaN(t.getTime())) t = new Date(s);
|
||||
var diff = Math.floor((Date.now() - t.getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
csrf() {
|
||||
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
},
|
||||
refreshCount() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/unread-count', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { self.unread = d.unread || 0; })
|
||||
.catch(function () {});
|
||||
},
|
||||
load() {
|
||||
var self = this;
|
||||
this.loading = true;
|
||||
fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
self.items = d.notifications || [];
|
||||
self.unread = d.unread || 0;
|
||||
self.loading = false;
|
||||
})
|
||||
.catch(function () { self.loading = false; });
|
||||
},
|
||||
openItem(n) {
|
||||
if (!n.is_read) {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
||||
body: JSON.stringify({ id: n.id })
|
||||
}).then(function () { self.refreshCount(); self.load(); });
|
||||
}
|
||||
if (n.url) window.location.href = n.url;
|
||||
this.open = false;
|
||||
},
|
||||
markAllRead() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
||||
body: JSON.stringify({})
|
||||
}).then(function () { self.refreshCount(); self.load(); });
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -417,6 +417,69 @@
|
||||
style="display: none; position: fixed; z-index: 999"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ @mention autocomplete (v4.9.0) ═══════════ -->
|
||||
<div id="_mentionMenu" class="mention-menu"
|
||||
style="display:none;position:fixed;z-index:1200;min-width:240px;max-height:280px;overflow-y:auto;
|
||||
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
||||
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
||||
|
||||
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
|
||||
<button type="button" id="_commentSelBtn"
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
|
||||
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
|
||||
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
|
||||
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
||||
<div class="comments-drawer-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Comments</span>
|
||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
No comments yet. Select some text and click 💬 Comment.
|
||||
</div>
|
||||
<template x-for="c in comments" :key="c.id">
|
||||
<div class="comment-thread" style="margin-bottom:18px;"
|
||||
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
|
||||
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
|
||||
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
|
||||
</div>
|
||||
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
|
||||
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
|
||||
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
|
||||
@click="deleteComment(c.id)">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
|
||||
<textarea x-model="commentDraft" rows="2"
|
||||
placeholder="Add a comment... Use @ to mention someone."
|
||||
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
|
||||
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
|
||||
@@ -298,6 +298,11 @@
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'editor',accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
||||
currentUserId:{{ user.get('id', 0) if user else 0 }},
|
||||
_commentSel:{range:null,bid:null,start:0,end:0},
|
||||
_notifiedMentions:{},
|
||||
get timeAgo() {
|
||||
if (!this.updatedAt) return 'just now';
|
||||
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
|
||||
@@ -679,6 +684,110 @@
|
||||
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();}},
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
|
||||
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
|
||||
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
|
||||
csrfTok(){return (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'';},
|
||||
async addPageComment(){
|
||||
const text=(this.commentDraft||'').trim();if(!text)return;
|
||||
const self=this;const sel=this._commentSel;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/comments',{method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
|
||||
const d=await r.json();
|
||||
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};this.hideCommentBtn();await this.loadComments();this.showToast('Comment added');}
|
||||
else{this.showToast(d.detail||'Failed to add comment');}
|
||||
}catch(e){this.showToast('Failed to add comment');}
|
||||
},
|
||||
async resolveComment(id){try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({resolved:true})});await this.loadComments();}catch(e){}},
|
||||
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});await this.loadComments();}catch(e){}},
|
||||
commentOnSelection(){
|
||||
const sel=this._commentSel;if(!sel.range)return;
|
||||
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
|
||||
const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';
|
||||
this.$nextTick(()=>{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();});
|
||||
},
|
||||
showCommentBtn(){
|
||||
const s=window.getSelection();if(!s||s.isCollapsed)return;
|
||||
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
|
||||
const bel=s.anchorNode.parentElement? s.anchorNode.parentElement.closest('[data-bid]'):null;
|
||||
if(!bel)return;
|
||||
const bid=bel.dataset.bid;
|
||||
const range=s.getRangeAt(0).cloneRange();range.selectNodeContents(bel);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
|
||||
const start=range.toString().length;
|
||||
const end=start+s.toString().length;
|
||||
if(end<=start)return;
|
||||
this._commentSel={range:s.getRangeAt(0).cloneRange(),bid:bid,start:start,end:end};
|
||||
const r=s.getRangeAt(0).getBoundingClientRect();
|
||||
const btn=document.getElementById('_commentSelBtn');if(!btn)return;
|
||||
btn.style.display='block';
|
||||
const bw=btn.offsetWidth||110,bh=btn.offsetHeight||34;
|
||||
let x=r.left+r.width/2-bw/2,y=r.top-bh-8;
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-bw-8);if(y<8)y=r.bottom+8;
|
||||
btn.style.left=x+'px';btn.style.top=y+'px';
|
||||
},
|
||||
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
|
||||
// ── @mention autocomplete ──
|
||||
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null,
|
||||
openMention(el){
|
||||
const s=window.getSelection();if(!s.rangeCount)return;
|
||||
const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset;
|
||||
const before=text.substring(0,pos);
|
||||
const m=before.match(/@([\w\-\.]*)$/);
|
||||
if(!m)return;
|
||||
this.mentionQuery=m[1];this.mentionEl=el;
|
||||
const r=s.getRangeAt(0).getBoundingClientRect();
|
||||
const menu=document.getElementById('_mentionMenu');
|
||||
menu.style.display='block';
|
||||
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px';
|
||||
menu.style.left=Math.min(r.left,window.innerWidth-260)+'px';
|
||||
this.mentionOpen=true;this.loadMentionUsers();
|
||||
},
|
||||
async loadMentionUsers(){
|
||||
const q=this.mentionQuery||'';
|
||||
try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];}
|
||||
},
|
||||
renderMentionMenu(){
|
||||
const menu=document.getElementById('_mentionMenu');if(!menu)return;
|
||||
let h='';const self=this;
|
||||
(this.mentionResults||[]).forEach(function(u,idx){
|
||||
h+='<div class="mention-item" data-login="'+u.login+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
|
||||
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">'
|
||||
+((u.avatar_url)?'<img src="'+u.avatar_url+'" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">':((u.full_name||u.login||'?').charAt(0).toUpperCase()))
|
||||
+'</div><div><div style="font-weight:500">'+u.login+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+(u.full_name||'')+'</div></div></div>';
|
||||
});
|
||||
if(!h)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No users found</div>';
|
||||
menu.innerHTML=h;
|
||||
menu.querySelectorAll('.mention-item').forEach(function(it){
|
||||
it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));});
|
||||
});
|
||||
},
|
||||
insertMention(login){
|
||||
const el=this.mentionEl;if(!el)return;
|
||||
const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();}
|
||||
const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length;
|
||||
const before=text.substring(0,pos);
|
||||
const at=before.lastIndexOf('@');
|
||||
const full=before.substring(0,at)+'@'+login+' ';
|
||||
const after=text.substring(pos);
|
||||
el.textContent=full+after;
|
||||
const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr);
|
||||
this.closeMention();this.dirty=true;this.autoSave();
|
||||
this.notifyNewMentions(full);
|
||||
},
|
||||
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
|
||||
notifyNewMentions(text){
|
||||
const self=this;
|
||||
const handles=[];const re=/@([\w\-\.]+)/g;let mm;
|
||||
while((mm=re.exec(text)))handles.push(mm[1].toLowerCase());
|
||||
const fresh=handles.filter(function(h){return !self._notifiedMentions[h];});
|
||||
if(!fresh.length)return;
|
||||
fetch('/api/pages/'+this.pid+'/mentions',{method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({text:'@'+fresh.join(' @')})}).catch(function(){});
|
||||
},
|
||||
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
|
||||
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
||||
save(cb){
|
||||
@@ -711,7 +820,29 @@
|
||||
}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);});
|
||||
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);d.showCommentBtn();}else{d.hideCommentBtn();}},80);});
|
||||
document.addEventListener('mousedown',()=>{setTimeout(()=>{const ed=document.querySelector('.page-editor-wrapper');if(ed?.__x)ed.__x.$data.hideCommentBtn();},10);});
|
||||
document.addEventListener('keyup',(e)=>{
|
||||
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
|
||||
const d=ed.__x.$data;if(!d.mentionOpen){if(e.key!=='@')return;}
|
||||
const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return;
|
||||
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(el))return;
|
||||
if(d.mentionOpen){
|
||||
if(e.key==='Escape'){d.closeMention();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();const items=document.querySelectorAll('#_mentionMenu .mention-item');if(!items.length)return;let idx=[].indexOf.call(items,document.querySelector('#_mentionMenu .mention-item.hover'));idx+= (e.key==='ArrowDown'?1:-1);if(idx<0)idx=items.length-1;if(idx>=items.length)idx=0;items.forEach(function(x){x.classList.remove('hover');});items[idx].classList.add('hover');return;}
|
||||
if(e.key==='Enter'){e.preventDefault();const hover=document.querySelector('#_mentionMenu .mention-item.hover')||document.querySelector('#_mentionMenu .mention-item');if(hover)hover.click();return;}
|
||||
}
|
||||
d.openMention(el);
|
||||
},true);
|
||||
document.addEventListener('click',(e)=>{
|
||||
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
|
||||
const d=ed.__x.$data;if(!d.mentionOpen)return;
|
||||
const menu=document.getElementById('_mentionMenu');
|
||||
if(menu&&!menu.contains(e.target))d.closeMention();
|
||||
});
|
||||
var fdStyle=document.createElement('style');
|
||||
fdStyle.textContent='#_mentionMenu .mention-item:hover,#_mentionMenu .mention-item.hover{background:rgba(35,131,226,.15);}';
|
||||
document.head.appendChild(fdStyle);
|
||||
function blocksToMarkdown(b) {
|
||||
var c = b.content || '';
|
||||
switch(b.type) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% 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" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><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' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -220,14 +220,24 @@
|
||||
<div class="setting-label">Comments</div>
|
||||
<div class="setting-desc">When someone comments on your pages</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" x-model="notifPrefs.comments" @change="saveNotifPrefs()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Mentions</div>
|
||||
<div class="setting-desc">When someone @mentions you</div>
|
||||
</div>
|
||||
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" x-model="notifPrefs.mentions" @change="saveNotifPrefs()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -517,6 +527,7 @@ document.addEventListener('alpine:init', function() {
|
||||
defaultView: localStorage.getItem('fd_default_view') || 'tree',
|
||||
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
|
||||
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
|
||||
notifPrefs: {comments: true, mentions: true},
|
||||
// Auth & Integrations
|
||||
authMethod: '{{ auth_method }}',
|
||||
giteaLinked: false,
|
||||
@@ -541,6 +552,23 @@ document.addEventListener('alpine:init', function() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
await this.loadGithubStatus();
|
||||
await this.loadNotifPrefs();
|
||||
},
|
||||
|
||||
async loadNotifPrefs() {
|
||||
try {
|
||||
var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.notifPrefs = Object.assign({comments:true, mentions:true}, d.prefs || {});
|
||||
} catch(e) {}
|
||||
},
|
||||
async saveNotifPrefs() {
|
||||
try {
|
||||
await fetch('/api/notifications/prefs', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify(this.notifPrefs)
|
||||
});
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async loadTags() {
|
||||
|
||||
Reference in New Issue
Block a user