mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
filter added.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 & 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user