add option to re-order accounts

This commit is contained in:
ghostersk
2026-03-15 13:15:46 +00:00
parent 015c00251b
commit 1e08d5f50f
10 changed files with 330 additions and 39 deletions

View File

@@ -233,6 +233,9 @@ func main() {
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
// Search
api.HandleFunc("/search", h.API.Search).Methods("GET")

View File

@@ -170,7 +170,6 @@ func (d *DB) Migrate() error {
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
// Default: primary folder types sync by default, others don't.
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
@@ -178,6 +177,10 @@ func (d *DB) Migrate() error {
// Per-folder IMAP sync state for incremental/delta sync.
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
// Account display order for sidebar drag-and-drop reordering.
`ALTER TABLE email_accounts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`,
// UI preferences (JSON): collapsed accounts/folders, etc. Synced across devices.
`ALTER TABLE users ADD COLUMN ui_prefs TEXT NOT NULL DEFAULT '{}'`,
}
for _, stmt := range alterStmts {
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
@@ -623,14 +626,14 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) {
access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at,
COALESCE(sync_days,30), COALESCE(sync_mode,'days')
COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0)
FROM email_accounts WHERE id=?`, accountID,
).Scan(
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SyncDays, &a.SyncMode,
&a.SyncDays, &a.SyncMode, &a.SortOrder,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -763,8 +766,10 @@ func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) {
SELECT id, user_id, provider, email_address, display_name,
access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at
FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY created_at`, userID)
last_error, color, is_active, last_sync, created_at,
COALESCE(sort_order,0)
FROM email_accounts WHERE user_id=? AND is_active=1
ORDER BY COALESCE(sort_order,0), created_at`, userID)
if err != nil {
return nil, err
}
@@ -783,6 +788,7 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) {
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SortOrder,
); err != nil {
return nil, err
}
@@ -805,6 +811,104 @@ func (d *DB) DeleteAccount(accountID, userID int64) error {
return err
}
// UpsertOAuthAccount inserts a new OAuth account or updates tokens/display name
// if an account with the same (user_id, provider, email_address) already exists.
// Used by OAuth callbacks so that re-connecting updates rather than duplicates.
func (d *DB) UpsertOAuthAccount(a *models.EmailAccount) (created bool, err error) {
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
refreshEnc, _ := d.enc.Encrypt(a.RefreshToken)
// Check for existing account with same user + provider + email
var existingID int64
row := d.sql.QueryRow(
`SELECT id FROM email_accounts WHERE user_id=? AND provider=? AND email_address=?`,
a.UserID, a.Provider, a.EmailAddress,
)
scanErr := row.Scan(&existingID)
if scanErr == sql.ErrNoRows {
// New account — insert with next sort_order
var maxOrder int
d.sql.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM email_accounts WHERE user_id=?`, a.UserID).Scan(&maxOrder)
res, insertErr := d.sql.Exec(`
INSERT INTO email_accounts
(user_id, provider, email_address, display_name, access_token, refresh_token,
token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.UserID, a.Provider, a.EmailAddress, a.DisplayName,
accessEnc, refreshEnc, a.TokenExpiry,
"", a.IMAPPort, "", a.SMTPPort,
a.Color, maxOrder+1,
)
if insertErr != nil {
return false, insertErr
}
id, _ := res.LastInsertId()
a.ID = id
return true, nil
}
if scanErr != nil {
return false, scanErr
}
// Existing account — update tokens and display name only.
// If refresh token is empty (Microsoft omits it after first auth),
// keep the existing one to avoid losing the ability to auto-refresh.
if a.RefreshToken != "" {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, refresh_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, refreshEnc, a.TokenExpiry, existingID,
)
} else {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, a.TokenExpiry, existingID,
)
}
a.ID = existingID
return false, err
}
// UpdateAccountSortOrder sets sort_order for a batch of accounts for a user.
// accountIDs is ordered from first to last in the desired display order.
func (d *DB) UpdateAccountSortOrder(userID int64, accountIDs []int64) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
for i, id := range accountIDs {
if _, err := tx.Exec(
`UPDATE email_accounts SET sort_order=? WHERE id=? AND user_id=?`,
i, id, userID,
); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetUIPrefs returns the JSON ui_prefs string for a user.
func (d *DB) GetUIPrefs(userID int64) (string, error) {
var prefs string
err := d.sql.QueryRow(`SELECT COALESCE(ui_prefs,'{}') FROM users WHERE id=?`, userID).Scan(&prefs)
if err != nil {
return "{}", err
}
return prefs, nil
}
// SetUIPrefs stores the JSON ui_prefs string for a user.
func (d *DB) SetUIPrefs(userID int64, prefs string) error {
_, err := d.sql.Exec(`UPDATE users SET ui_prefs=? WHERE id=?`, prefs, userID)
return err
}
// UpdateFolderCounts refreshes the unread/total counts for a folder.
func (d *DB) UpdateFolderCounts(folderID int64) {
d.sql.Exec(`
UPDATE folders SET

View File

@@ -63,6 +63,7 @@ type safeAccount struct {
SMTPPort int `json:"smtp_port,omitempty"`
SyncDays int `json:"sync_days"`
SyncMode string `json:"sync_mode"`
SortOrder int `json:"sort_order"`
LastError string `json:"last_error,omitempty"`
Color string `json:"color"`
LastSync string `json:"last_sync"`
@@ -82,7 +83,7 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
SyncDays: a.SyncDays, SyncMode: a.SyncMode,
SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder,
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
TokenExpired: tokenExpired,
}
@@ -486,6 +487,52 @@ func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) SetAccountSortOrder(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Order []int64 `json:"order"` // account IDs in desired display order
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Order) == 0 {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.UpdateAccountSortOrder(userID, req.Order); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save order")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) GetUIPrefs(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
prefs, err := h.db.GetUIPrefs(userID)
if err != nil {
prefs = "{}"
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(prefs))
}
func (h *APIHandler) SetUIPrefs(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil || len(body) == 0 {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
// Validate it's valid JSON before storing
var check map[string]interface{}
if err := json.Unmarshal(body, &check); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.db.SetUIPrefs(userID, string(body)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Messages ----
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {

View File

@@ -311,12 +311,17 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); err != nil {
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
action := "gmail:" + userInfo.Email
if !created {
action = "gmail-reconnect:" + userInfo.Email
}
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
}
@@ -331,7 +336,10 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
state := encodeOAuthState(userID, "outlook")
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token,
// even when the user has previously authorized the app.
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
oauth2.SetAuthURLParam("prompt", "consent"))
http.Redirect(w, r, url, http.StatusFound)
}
@@ -364,12 +372,17 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); err != nil {
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
action := "outlook:" + userInfo.Email()
if !created {
action = "outlook-reconnect:" + userInfo.Email()
}
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
}

View File

@@ -113,6 +113,7 @@ type EmailAccount struct {
// Display
Color string `json:"color"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
LastSync time.Time `json:"last_sync"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -165,7 +165,15 @@ body.app-page{overflow:hidden}
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px}
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px;
cursor:pointer;user-select:none;border-radius:6px;transition:background .15s}
.nav-folder-header:hover{background:var(--surface3)}
.acc-drag-handle{cursor:grab;color:var(--muted);font-size:13px;opacity:.5;flex-shrink:0;line-height:1}
.acc-drag-handle:hover{opacity:1}
.acc-chevron{flex-shrink:0;color:var(--muted);display:flex;align-items:center}
.nav-account-group{border-radius:6px;transition:background .15s}
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
.nav-account-group.acc-dragging{opacity:.4}
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
align-items:center;justify-content:space-between;flex-shrink:0}
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}

View File

@@ -9,12 +9,27 @@ const S = {
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
filterUnread: false, filterAttachment: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.)
};
// ── UI Preferences (server-persisted, cross-device) ─────────────────────────
let _uiPrefsSaveTimer = null;
function uiPrefsGet(key, def) { return (key in S.uiPrefs) ? S.uiPrefs[key] : def; }
function uiPrefsSet(key, val) {
S.uiPrefs[key] = val;
clearTimeout(_uiPrefsSaveTimer);
_uiPrefsSaveTimer = setTimeout(() => {
api('PUT', '/ui-prefs', S.uiPrefs);
}, 600); // debounce 600ms
}
function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); }
function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); }
// ── Boot ───────────────────────────────────────────────────────────────────
async function init() {
const [me, providers, wl] = await Promise.all([
const [me, providers, wl, uiPrefsRaw] = await Promise.all([
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
api('GET','/ui-prefs'),
]);
if (me) {
S.me = me;
@@ -23,6 +38,7 @@ async function init() {
}
if (providers) { S.providers = providers; updateProviderButtons(); }
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
await loadAccounts();
await loadFolders();
@@ -33,8 +49,23 @@ async function init() {
}
const p = new URLSearchParams(location.search);
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); }
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
if (p.get('connected')) {
toast('Account connected! Loading…', 'success');
history.replaceState({},'','/');
// Poll until the new account appears (syncer needs a moment to start)
let tries = 0;
const prevCount = S.accounts.length;
const poll = setInterval(async () => {
tries++;
await loadAccounts();
await loadFolders();
if (S.accounts.length > prevCount || tries >= 12) {
clearInterval(poll);
if (S.accounts.length > prevCount) toast('Account ready!', 'success');
}
}, 2500);
}
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
@@ -370,32 +401,116 @@ const FOLDER_ICONS = {
};
function renderFolders() {
const el=document.getElementById('folders-by-account');
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
const byAcc={};
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])=>{
const acc=accMap[parseInt(accId)];
const accColor = acc?.color || '#888';
const accEmail = acc?.email_address || 'Account '+accId;
if(!folders?.length) return '';
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>
<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}" data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
const el = document.getElementById('folders-by-account');
const accMap = {}; S.accounts.forEach(a => accMap[a.id] = a);
const byAcc = {};
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'];
const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
el.innerHTML = orderedAccounts.map(acc => {
const folders = byAcc[acc.id];
if (!folders?.length) return '';
const accId = acc.id;
const collapsed = isAccountCollapsed(accId);
const sorted = [
...prio.map(t => folders.find(f => f.folder_type===t)).filter(Boolean),
...folders.filter(f => f.folder_type==='custom')
];
const totalUnread = folders.reduce((s,f) => s+(f.unread_count||0), 0);
const chevron = collapsed
? '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'
: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>';
const folderRows = collapsed ? '' : sorted.map(f => `
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}"
data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
oncontextmenu="showFolderMenu(event,${f.id})">
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
${esc(f.name)}
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled"></span>':''}
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">\u29b8</span>':''}
</div>`).join('');
return `<div class="nav-account-group" data-acc-id="${accId}"
draggable="true"
ondragstart="accDragStart(event,${accId})"
ondragover="accDragOver(event)"
ondragleave="accDragLeave(event)"
ondrop="accDrop(event,${accId})">
<div class="nav-folder-header" onclick="toggleAccountCollapse(${accId})">
<span class="acc-drag-handle" title="Drag to reorder" onclick="event.stopPropagation()">&#8942;</span>
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
${totalUnread>0&&collapsed?`<span class="unread-badge" style="margin-left:auto">${totalUnread}</span>`:''}
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${accId},event)" style="flex-shrink:0">
<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>
<span class="acc-chevron">${chevron}</span>
</div>
${folderRows}
</div>`;
}).join('');
// Re-wire drag-drop onto folder rows for message-to-folder moves
el.querySelectorAll('.nav-item[data-fid]').forEach(item => {
item.ondragover = e => { e.preventDefault(); item.classList.add('drag-over'); };
item.ondragleave = () => item.classList.remove('drag-over');
item.ondrop = e => {
e.preventDefault(); item.classList.remove('drag-over');
const fid = parseInt(item.dataset.fid);
const mid = parseInt(e.dataTransfer.getData('text/plain'));
if (mid && fid) moveMessage(mid, fid);
};
});
}
function toggleAccountCollapse(accId) {
setAccountCollapsed(accId, !isAccountCollapsed(accId));
renderFolders();
}
// ── Account drag-to-reorder ─────────────────────────────────────────────────
let _dragSrcAccId = null;
function accDragStart(e, accId) {
_dragSrcAccId = accId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(accId));
setTimeout(() => e.currentTarget?.classList.add('acc-dragging'), 0);
}
function accDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const g = e.currentTarget;
if (g && parseInt(g.dataset.accId) !== _dragSrcAccId) g.classList.add('acc-drag-target');
}
function accDragLeave(e) { e.currentTarget?.classList.remove('acc-drag-target'); }
async function accDrop(e, targetAccId) {
e.preventDefault();
e.currentTarget?.classList.remove('acc-drag-target');
document.querySelectorAll('.acc-dragging').forEach(el => el.classList.remove('acc-dragging'));
if (_dragSrcAccId === null || _dragSrcAccId === targetAccId) { _dragSrcAccId = null; return; }
const ordered = [...S.accounts].sort((a,b) => (a.sort_order||0)-(b.sort_order||0));
const srcIdx = ordered.findIndex(a => a.id === _dragSrcAccId);
const dstIdx = ordered.findIndex(a => a.id === targetAccId);
if (srcIdx === -1 || dstIdx === -1) { _dragSrcAccId = null; return; }
const [moved] = ordered.splice(srcIdx, 1);
ordered.splice(dstIdx, 0, moved);
ordered.forEach((a, i) => { a.sort_order = i; });
S.accounts = ordered;
_dragSrcAccId = null;
renderFolders();
await api('PUT', '/accounts/sort-order', { order: ordered.map(a => a.id) });
}
function showFolderMenu(e, folderId) {

View File

@@ -39,5 +39,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js?v=24"></script>
<script src="/static/js/admin.js?v=25"></script>
{{end}}

View File

@@ -368,5 +368,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=24"></script>
<script src="/static/js/app.js?v=25"></script>
{{end}}

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{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/gowebmail.css?v=24">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=25">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=24"></script>
<script src="/static/js/gowebmail.js?v=25"></script>
{{block "scripts" .}}{{end}}
</body>
</html>