filter added.

This commit is contained in:
ghostersk
2026-03-07 16:49:23 +00:00
parent b118056176
commit 6df2de5f22
8 changed files with 309 additions and 43 deletions

View File

@@ -135,6 +135,9 @@ func main() {
api.HandleFunc("/folders/{account_id:[0-9]+}", h.API.ListAccountFolders).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}/sync", h.API.SyncFolder).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/visibility", h.API.SetFolderVisibility).Methods("PUT")
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")

View File

@@ -1081,6 +1081,40 @@ func (d *DB) SetFolderVisibility(folderID, userID int64, isHidden, syncEnabled b
return err
}
// CountFolderMessages returns how many messages are in a folder (owned by user).
func (d *DB) CountFolderMessages(folderID, userID int64) (int, error) {
var count int
err := d.sql.QueryRow(`
SELECT COUNT(*) FROM messages m
JOIN folders f ON f.id=m.folder_id
JOIN email_accounts a ON a.id=f.account_id
WHERE m.folder_id=? AND a.user_id=?`, folderID, userID).Scan(&count)
return count, err
}
// DeleteFolder removes a folder and all its messages (cascade).
func (d *DB) DeleteFolder(folderID, userID int64) error {
_, err := d.sql.Exec(`
DELETE FROM folders WHERE id=?
AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
folderID, userID)
return err
}
// MoveFolderContents moves all messages from one folder to another (both must belong to user).
func (d *DB) MoveFolderContents(fromID, toID, userID int64) (int64, error) {
res, err := d.sql.Exec(`
UPDATE messages SET folder_id=?
WHERE folder_id=?
AND folder_id IN (SELECT f.id FROM folders f JOIN email_accounts a ON a.id=f.account_id WHERE a.user_id=?)`,
toID, fromID, userID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) {
f := &models.Folder{}
var isHidden, syncEnabled int

View File

@@ -390,6 +390,39 @@ func (h *APIHandler) SetFolderVisibility(w http.ResponseWriter, r *http.Request)
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) CountFolderMessages(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
count, err := h.db.CountFolderMessages(folderID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "count failed")
return
}
h.writeJSON(w, map[string]int{"count": count})
}
func (h *APIHandler) DeleteFolder(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
if err := h.db.DeleteFolder(folderID, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) MoveFolderContents(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
fromID := pathInt64(r, "id")
toID := pathInt64(r, "toId")
moved, err := h.db.MoveFolderContents(fromID, toID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "move failed")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true, "moved": moved})
}
func (h *APIHandler) SetAccountSyncSettings(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "id")

View File

@@ -191,9 +191,14 @@ body.app-page{overflow:hidden}
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
.message-item:hover{background:var(--surface2)}
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
.message-item.unread .msg-subject{font-weight:500;color:var(--text)}
.message-item.unread::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
width:3px;height:22px;background:var(--accent);border-radius:0 2px 2px 0}
/* Unread: lighter background + bold sender so it pops clearly */
.message-item.unread{background:rgba(255,255,255,.035)}
.message-item.unread:hover{background:rgba(255,255,255,.055)}
.message-item.unread .msg-from{color:var(--text);font-weight:600}
.message-item.unread .msg-subject{font-weight:600;color:var(--text)}
.message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0;
width:3px;background:var(--accent);border-radius:0 2px 2px 0}
.message-item.unread.active{background:var(--accent-dim)}
.message-item.unread.active::before{display:none}
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
@@ -253,7 +258,7 @@ body.app-page{overflow:hidden}
cursor:grab;flex-shrink:0;background:var(--surface2);
}
.compose-dialog-header:active{cursor:grabbing}
.compose-body-wrap{display:flex;flex-direction:column;flex:1;overflow:hidden}
.compose-body-wrap{display:flex;flex-direction:column;flex:1;overflow:hidden;min-height:0}
.compose-title{font-size:13px;font-weight:500;pointer-events:none}
.compose-close{background:none;border:none;color:var(--muted);font-size:17px;cursor:pointer;
line-height:1;padding:2px 5px;border-radius:4px;pointer-events:all}
@@ -344,8 +349,8 @@ body.admin-page{overflow:auto;background:var(--bg)}
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
.fmt-btn:hover{background:var(--border2);color:var(--text)}
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
.compose-editor{flex:1;min-height:160px;max-height:320px;overflow-y:auto;padding:12px 14px;
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg)}
.compose-editor{flex:1;overflow-y:auto;padding:12px 14px;
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0}
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
.compose-editor .quote-divider{font-size:11px;color:var(--muted);margin:10px 0 4px}
@@ -408,13 +413,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
/* ── Inline confirm toast ────────────────────────────────────── */
.inline-confirm{
position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
background:var(--surface2);border:1px solid var(--border2);border-radius:10px;
box-shadow:0 12px 40px rgba(0,0,0,.55);padding:16px 18px;
min-width:280px;max-width:400px;z-index:400;
position:fixed;top:50%;left:50%;transform:translate(-50%,-44%);
background:var(--surface2);border:1px solid var(--border2);border-radius:12px;
box-shadow:0 24px 64px rgba(0,0,0,.7);padding:20px 22px;
min-width:300px;max-width:440px;z-index:400;
opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
}
.inline-confirm.open{opacity:1;pointer-events:all;transform:translateX(-50%) translateY(0)}
.inline-confirm.open{opacity:1;pointer-events:all;transform:translate(-50%,-50%)}
/* ── Folder no-sync indicator ────────────────────────────────── */
.folder-nosync{opacity:.65}
@@ -426,3 +431,20 @@ body.admin-page{overflow:auto;background:var(--bg)}
.icon-sync-btn{background:none;border:none;color:var(--muted);cursor:pointer;
padding:2px;border-radius:4px;line-height:1;flex-shrink:0;transition:color .15s}
.icon-sync-btn:hover{color:var(--text)}
/* ── Message filter dropdown ─────────────────────────────────── */
.filter-dropdown{position:relative}
.filter-dropdown-btn{display:flex;align-items:center;gap:5px;background:none;
border:1px solid var(--border2);border-radius:6px;color:var(--muted);
font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
padding:4px 9px;transition:all .1s;white-space:nowrap}
.filter-dropdown-btn:hover{background:var(--surface3);color:var(--text)}
.filter-dropdown-btn.active{border-color:rgba(91,141,239,.4);color:var(--accent);background:var(--accent-dim)}
.filter-dropdown-menu{position:absolute;top:calc(100% + 6px);right:0;
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
box-shadow:0 8px 28px rgba(0,0,0,.5);min-width:160px;padding:4px;
z-index:250}
.filter-opt{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;
color:var(--text2);transition:background .1s;white-space:nowrap}
.filter-opt:hover{background:var(--surface3);color:var(--text)}
.filter-sep-line{height:1px;background:var(--border);margin:3px 0}

View File

@@ -10,6 +10,9 @@ const S = {
remoteWhitelist: new Set(),
draftTimer: null, draftDirty: false,
composeVisible: false, composeMinimised: false,
// Message list filters
filterUnread: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
};
// ── Boot ───────────────────────────────────────────────────────────────────
@@ -280,7 +283,6 @@ function renderFolders() {
const el=document.getElementById('folders-by-account');
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
const byAcc={};
// Only show non-hidden folders
S.folders.filter(f=>!f.is_hidden).forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
const prio=['inbox','sent','drafts','trash','spam','archive'];
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
@@ -291,7 +293,10 @@ function renderFolders() {
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')];
return `<div class="nav-folder-header">
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span>
${esc(accEmail)}
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(accEmail)}</span>
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${parseInt(accId)},event)" style="margin-left:4px">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
</button>
</div>`+sorted.map(f=>`
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
oncontextmenu="showFolderMenu(event,${f.id})">
@@ -308,12 +313,15 @@ function showFolderMenu(e, folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const syncLabel = f.sync_enabled ? '⊘ Disable sync' : '↻ Enable sync';
const hideLabel = '👁 Hide from sidebar';
const otherFolders = S.folders.filter(x=>x.id!==folderId&&x.account_id===f.account_id&&!x.is_hidden).slice(0,12);
const moveSub = otherFolders.map(x=>`<div class="ctx-item" style="padding-left:22px" onclick="moveFolderContents(${folderId},${x.id});closeMenu()">${esc(x.name)}</div>`).join('');
showCtxMenu(e, `
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="hideFolder(${folderId});closeMenu()">${hideLabel}</div>`);
${moveSub?`<div style="font-size:10px;color:var(--muted);padding:4px 12px;text-transform:uppercase;letter-spacing:.7px">Move messages to</div>${moveSub}<div class="ctx-sep"></div>`:''}
<div class="ctx-item" onclick="confirmHideFolder(${folderId});closeMenu()">👁 Hide from sidebar</div>
<div class="ctx-item danger" onclick="confirmDeleteFolder(${folderId});closeMenu()">🗑 Delete folder</div>`);
}
async function syncFolderNow(folderId) {
@@ -335,14 +343,50 @@ async function toggleFolderSync(folderId) {
} else toast('Update failed','error');
}
async function hideFolder(folderId) {
async function confirmHideFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const r = await api('PUT','/folders/'+folderId+'/visibility',{is_hidden:true, sync_enabled:false});
if (r?.ok) {
toast('Folder hidden. Manage in account settings.','success');
await loadFolders();
} else toast('Update failed','error');
inlineConfirm(
`Hide "${f.name}" from sidebar? You can unhide it from account settings.`,
async () => {
const r = await api('PUT','/folders/'+folderId+'/visibility',{is_hidden:true, sync_enabled:false});
if (r?.ok) { toast('Folder hidden','success'); await loadFolders(); }
else toast('Update failed','error');
}
);
}
async function confirmDeleteFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId);
if (!f) return;
const countRes = await api('GET','/folders/'+folderId+'/count');
const count = countRes?.count ?? '?';
inlineConfirm(
`Delete folder "${f.name}"? This will permanently delete all ${count} message${count===1?'':'s'} inside it. This cannot be undone.`,
async () => {
const r = await api('DELETE','/folders/'+folderId);
if (r?.ok) {
toast('Folder deleted','success');
S.folders = S.folders.filter(x=>x.id!==folderId);
if (S.currentFolder===folderId) selectFolder('unified','Unified Inbox');
renderFolders(); loadMessages();
} else toast(r?.error||'Delete failed','error');
}
);
}
async function moveFolderContents(fromId, toId) {
const from = S.folders.find(f=>f.id===fromId);
const to = S.folders.find(f=>f.id===toId);
if (!from||!to) return;
inlineConfirm(
`Move all messages from "${from.name}" into "${to.name}"?`,
async () => {
const r = await api('POST','/folders/'+fromId+'/move-to/'+toId);
if (r?.ok) { toast(`Moved ${r.moved||0} messages`,'success'); loadFolders(); loadMessages(); }
else toast(r?.error||'Move failed','error');
}
);
}
function updateUnreadBadge() {
@@ -386,13 +430,52 @@ async function loadMessages(append) {
document.getElementById('panel-count').textContent=S.totalMessages>0?S.totalMessages+' messages':'';
}
function setFilter(mode) {
S.filterUnread = (mode === 'unread');
S.sortOrder = (mode === 'unread' || mode === 'default') ? 'date-desc' : mode;
// Update checkmarks
['default','unread','date-desc','date-asc','size-desc'].forEach(k => {
const el = document.getElementById('fopt-'+k);
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
});
// Update button label
const labels = {
'default':'Filter', 'unread':'Unread', 'date-desc':'↓ Date',
'date-asc':'↑ Date', 'size-desc':'↓ Size'
};
const labelEl = document.getElementById('filter-label');
if (labelEl) {
labelEl.textContent = labels[mode] || 'Filter';
labelEl.style.color = mode !== 'default' ? 'var(--accent)' : '';
}
const menuEl = document.getElementById('filter-dropdown-menu');
if (menuEl) menuEl.style.display = 'none';
renderMessageList();
}
// Keep old names as aliases so nothing else breaks
function toggleFilterUnread() { setFilter(S.filterUnread ? 'default' : 'unread'); }
function setSortOrder(order) { setFilter(order); }
function renderMessageList() {
const list=document.getElementById('message-list');
if (!S.messages.length){
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>No messages</p></div>`;
let msgs = [...S.messages];
// Filter
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
// Sort
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
else if (S.sortOrder === 'size-desc') msgs.sort((a,b) => (b.size||0)-(a.size||0));
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date)); // date-desc default
if (!msgs.length){
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${S.filterUnread?'No unread messages':'No messages'}</p></div>`;
return;
}
list.innerHTML=S.messages.map(m=>`
list.innerHTML=msgs.map(m=>`
<div class="message-item ${m.id===S.selectedMessageId?'active':''} ${!m.is_read?'unread':''}"
onclick="openMessage(${m.id})" oncontextmenu="showMessageMenu(event,${m.id})">
<div class="msg-top">
@@ -404,6 +487,7 @@ function renderMessageList() {
<div class="msg-meta">
<span class="msg-dot" style="background:${m.account_color}"></span>
<span class="msg-acct">${esc(m.account_email||'')}</span>
${m.size?`<span style="font-size:10px;color:var(--muted);margin-left:4px">${formatSize(m.size)}</span>`:''}
${m.has_attachment?'<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)" style="margin-left:4px"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>':''}
<span class="msg-star ${m.is_starred?'on':''}" onclick="toggleStar(${m.id},event)">${m.is_starred?'★':'☆'}</span>
</div>
@@ -538,9 +622,13 @@ async function markRead(id, read) {
}
async function moveMessage(msgId, folderId) {
const r=await api('PUT','/messages/'+msgId+'/move',{folder_id:folderId});
if(r?.ok){toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);renderMessageList();
if(S.currentMessage?.id===msgId)resetDetail();loadFolders();}
const folder = S.folders.find(f=>f.id===folderId);
inlineConfirm(`Move this message to "${folder?.name||'selected folder'}"?`, async () => {
const r=await api('PUT','/messages/'+msgId+'/move',{folder_id:folderId});
if(r?.ok){toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);renderMessageList();
if(S.currentMessage?.id===msgId)resetDetail();loadFolders();}
else toast('Move failed','error');
});
}
async function deleteMessage(id) {
@@ -771,13 +859,39 @@ async function sendMessage() {
}
// ── Compose drag + all-edge resize ─────────────────────────────────────────
function saveComposeGeometry(dlg) {
const r = dlg.getBoundingClientRect();
document.cookie = `compose_geo=${JSON.stringify({l:Math.round(r.left),t:Math.round(r.top),w:Math.round(r.width),h:Math.round(r.height)})};path=/;max-age=31536000`;
}
function loadComposeGeometry(dlg) {
try {
const m = document.cookie.match(/compose_geo=([^;]+)/);
if (!m) return false;
const g = JSON.parse(decodeURIComponent(m[1]));
if (!g.w||!g.h) return false;
const maxL = window.innerWidth - Math.max(360, g.w);
const maxT = window.innerHeight - Math.max(280, g.h);
dlg.style.left = Math.max(0, Math.min(g.l, maxL)) + 'px';
dlg.style.top = Math.max(0, Math.min(g.t, maxT)) + 'px';
dlg.style.width = Math.max(360, g.w) + 'px';
dlg.style.height = Math.max(280, g.h) + 'px';
dlg.style.right = 'auto'; dlg.style.bottom = 'auto';
const editor = document.getElementById('compose-editor');
if (editor) editor.style.height = (Math.max(280,g.h) - 242) + 'px';
return true;
} catch(e) { return false; }
}
function initComposeDragResize() {
const dlg=document.getElementById('compose-dialog');
if(!dlg) return;
// Default position — bottom-right
dlg.style.right='24px'; dlg.style.bottom='20px';
dlg.style.left='auto'; dlg.style.top='auto';
// Restore saved position/size, or fall back to default bottom-right
if (!loadComposeGeometry(dlg)) {
dlg.style.right='24px'; dlg.style.bottom='20px';
dlg.style.left='auto'; dlg.style.top='auto';
}
// Drag by header
const header=document.getElementById('compose-drag-handle');
@@ -793,7 +907,7 @@ function initComposeDragResize() {
dlg.style.left=Math.max(0,Math.min(window.innerWidth-dlg.offsetWidth, startL+(ev.clientX-ox)))+'px';
dlg.style.top= Math.max(0,Math.min(window.innerHeight-30, startT+(ev.clientY-oy)))+'px';
};
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); };
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); saveComposeGeometry(dlg); };
document.addEventListener('mousemove',mm);
document.addEventListener('mouseup',mu);
e.preventDefault();
@@ -820,7 +934,7 @@ function initComposeDragResize() {
const editor=document.getElementById('compose-editor');
if(editor) editor.style.height=(h-242)+'px';
};
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); };
const mu=()=>{ document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); saveComposeGeometry(dlg); };
document.addEventListener('mousemove',mm);
document.addEventListener('mouseup',mu);
e.preventDefault();
@@ -898,12 +1012,34 @@ function showCtxMenu(e, html) {
});
}
// ── Init tag fields on DOMContentLoaded ───────────────────────────────────
document.addEventListener('DOMContentLoaded', ()=>{
// Tag fields are re-init each time openCompose() is called.
// Initial init here covers the first open.
// ── Init tag fields and filter dropdown ───────────────────────────────────
// app.js loads at the bottom of <body> so the DOM is already ready here —
// we must NOT wrap in DOMContentLoaded (that event has already fired).
function _bootApp() {
initTagField('compose-to');
initTagField('compose-cc-tags');
initTagField('compose-bcc-tags');
// Filter dropdown
const dropBtn = document.getElementById('filter-dropdown-btn');
const dropMenu = document.getElementById('filter-dropdown-menu');
if (dropBtn && dropMenu) {
dropBtn.addEventListener('click', e => {
e.stopPropagation();
const isOpen = dropMenu.classList.contains('open');
dropMenu.classList.toggle('open', !isOpen);
if (!isOpen) {
document.addEventListener('click', () => dropMenu.classList.remove('open'), {once:true});
}
});
['default','unread','date-desc','date-asc','size-desc'].forEach(mode => {
const el = document.getElementById('fopt-'+mode);
if (el) el.addEventListener('click', e => { e.stopPropagation(); setFilter(mode); });
});
}
init();
});
}
// Run immediately — DOM is ready since this script is at end of <body>
_bootApp();

View File

@@ -108,3 +108,24 @@ function insertLink() {
document.getElementById('compose-editor').focus();
document.execCommand('createLink', false, url);
}
// ── Filter dropdown (stubs — real logic in app.js, but onclick needs global scope) ──
function goMailToggleFilter(e) {
e.stopPropagation();
const menu = document.getElementById('filter-dropdown-menu');
if (!menu) return;
const isOpen = menu.classList.contains('open');
menu.classList.toggle('open', !isOpen);
if (!isOpen) {
document.addEventListener('click', function closeFilter() {
menu.classList.remove('open');
document.removeEventListener('click', closeFilter);
});
}
}
function goMailSetFilter(mode) {
var menu = document.getElementById('filter-dropdown-menu');
if (menu) menu.style.display = 'none';
if (typeof setFilter === 'function') setFilter(mode);
}

View File

@@ -50,7 +50,24 @@
<div class="message-list-panel">
<div class="panel-header">
<span class="panel-title" id="panel-title">Unified Inbox</span>
<span class="panel-count" id="panel-count"></span>
<div style="display:flex;align-items:center;gap:6px">
<span class="panel-count" id="panel-count"></span>
<div class="filter-dropdown" id="filter-dropdown">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
<span id="filter-label">Filter</span>
</button>
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
</div>
</div>
</div>
</div>
<div class="search-bar">
<div class="search-wrap">
@@ -147,7 +164,7 @@
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
<div class="inline-confirm" id="inline-confirm">
<p id="inline-confirm-msg" style="margin:0 0 12px;font-size:13px"></p>
<p id="inline-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5"></p>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-confirm-cancel">Cancel</button>
<button class="btn-danger" style="font-size:12px" id="inline-confirm-ok">Confirm</button>
@@ -283,5 +300,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js"></script>
<script src="/static/js/app.js?v=7"></script>
{{end}}

View File

@@ -5,13 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gomail.css">
<link rel="stylesheet" href="/static/css/gomail.css?v=7">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gomail.js"></script>
<script src="/static/js/gomail.js?v=7"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}
{{end}}