mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
add option to re-order accounts
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()">⋮</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) {
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user