Add manual target drag and drop
This commit is contained in:
parent
ffa28f6182
commit
a4eee37ab9
4 changed files with 229 additions and 35 deletions
|
|
@ -21,6 +21,7 @@ func RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/view", viewerHandler)
|
mux.HandleFunc("/view", viewerHandler)
|
||||||
mux.HandleFunc("/view/message", messageHandler)
|
mux.HandleFunc("/view/message", messageHandler)
|
||||||
mux.HandleFunc("/view/target", targetMessageHandler)
|
mux.HandleFunc("/view/target", targetMessageHandler)
|
||||||
|
mux.HandleFunc("/view/manual-copy", manualCopyHandler)
|
||||||
mux.HandleFunc("/view/forward", forwardHandler)
|
mux.HandleFunc("/view/forward", forwardHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// renderShell ist das Grundgeruest im Outlook-2013-Look:
|
// renderShell ist das Grundgeruest im Outlook-2013-Look:
|
||||||
// [ Ribbon: Postfaecher | Betrachten | Betrachen | Einstellungen | Mail-Graveyard ]
|
// [ Ribbon: Postfaecher | Betrachten | Betrachen | Einstellungen | Mail-Graveyard ]
|
||||||
// [ Ordnerbaum | Nachrichtenliste | Original | Zielkopie ]
|
// [ Archiv-mbox | Nachrichtenliste | Quelle/Target ]
|
||||||
func renderShell(w http.ResponseWriter, active, readingPane string) {
|
func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
|
|
@ -53,7 +54,7 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="four-pane">
|
<div class="three-pane">
|
||||||
<aside class="pane pane-tree" id="tree"
|
<aside class="pane pane-tree" id="tree"
|
||||||
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
|
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
|
||||||
<div class="pane-head">Postfächer</div>
|
<div class="pane-head">Postfächer</div>
|
||||||
|
|
@ -64,17 +65,13 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
</section>
|
</section>
|
||||||
<div class="pane-resizer" data-resize-index="1"></div>
|
<div class="pane-resizer" data-resize-index="1"></div>
|
||||||
<section class="pane pane-read" id="read">
|
<section class="pane pane-read" id="read">
|
||||||
<div class="pane-head">Original</div>
|
<div class="pane-head">Quelle</div>
|
||||||
<div class="read-body">%s</div>
|
<div class="read-body">%s</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="pane-resizer" data-resize-index="2"></div>
|
|
||||||
<section class="pane pane-read pane-target" id="read-target">
|
|
||||||
%s
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
<footer class="statusbar"><span id="mig-status"
|
<footer class="statusbar"><span id="mig-status"
|
||||||
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
|
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
|
||||||
</body></html>`, active, readingPane, renderInitialTargetPane())
|
</body></html>`, active, readingPane)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) ---
|
// --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) ---
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013-
|
// Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013-
|
||||||
|
|
@ -53,10 +54,10 @@ func messageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderReadPanePair(w, account, folder, targetAccount, index, raw, "(unlesbar)", "", "", string(raw))
|
renderReadPaneWithSource(w, account, folder, targetAccount, index, "(unlesbar)", "", "", string(raw))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderReadPanePair(w, account, folder, targetAccount, index, raw,
|
renderReadPaneWithSource(w, account, folder, targetAccount, index,
|
||||||
decodeHeader(msg.Header.Get("Subject")),
|
decodeHeader(msg.Header.Get("Subject")),
|
||||||
decodeHeader(msg.Header.Get("From")),
|
decodeHeader(msg.Header.Get("From")),
|
||||||
decodeHeader(msg.Header.Get("Date")),
|
decodeHeader(msg.Header.Get("Date")),
|
||||||
|
|
@ -84,7 +85,7 @@ func targetMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw))
|
fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
func forwardHandler(w http.ResponseWriter, r *http.Request) {
|
func forwardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -157,7 +158,8 @@ func renderMessageList(w http.ResponseWriter, account, folder, q string) {
|
||||||
if from == "" {
|
if from == "" {
|
||||||
from = "(ohne Absender)"
|
from = "(ohne Absender)"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, `<a class="msg-row" href="#" hx-get="/view/message?source=%s&folder=%s&index=%d" hx-include="#target-account-select" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
|
fmt.Fprintf(w, `<a class="msg-row" href="#" draggable="true" data-source="%s" data-folder="%s" data-index="%d" hx-get="/view/message?source=%s&folder=%s&index=%d" hx-include="#target-account-select" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
|
||||||
|
html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), entry.Index,
|
||||||
urlEsc(sourceDisplayLabel(account)), urlEsc(folder), entry.Index,
|
urlEsc(sourceDisplayLabel(account)), urlEsc(folder), entry.Index,
|
||||||
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date))
|
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date))
|
||||||
}
|
}
|
||||||
|
|
@ -168,24 +170,60 @@ func renderMessageList(w http.ResponseWriter, account, folder, q string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderReadPanePair(w http.ResponseWriter, account, folder, targetAccount string, index int, raw []byte, subject, from, date, body string) {
|
func manualCopyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
account := resolveViewerAccount(r.FormValue("account"), r.FormValue("source"))
|
||||||
|
folder := r.FormValue("folder")
|
||||||
|
targetAccount := r.FormValue("target_account")
|
||||||
|
index, err := strconv.Atoi(r.FormValue("index"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad index", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := mboxPath(account, folder)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := ReadMboxMessage(path, index)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note, err := appendManualTargetCopy(account, folder, targetAccount, raw)
|
||||||
|
if err != nil {
|
||||||
|
note = "Fehler beim manuellen Umzug: " + err.Error()
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
renderReadPane(w, "Original", subject, from, date, body)
|
fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, note))
|
||||||
targetHTML := renderTargetPane(account, folder, targetAccount, index, raw)
|
|
||||||
fmt.Fprintf(w, `<div id="read-target" hx-swap-oob="innerHTML">%s</div>`, targetHTML)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderReadPane(w io.Writer, title, subject, from, date, body string) {
|
func renderReadPaneWithSource(w http.ResponseWriter, account, folder, targetAccount string, index int, subject, from, date, body string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
renderReadPane(w, "Quelle", account, folder, targetAccount, index, subject, from, date, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderReadPane(w io.Writer, title, account, folder, targetAccount string, index int, subject, from, date, body string) {
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "(ohne Betreff)"
|
subject = "(ohne Betreff)"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, `<div class="pane-head">%s</div><div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
|
fmt.Fprintf(w, `<div class="pane-head read-toolbar"><span>%s</span><button class="btn secondary compact" type="button" hx-get="/view/target?source=%s&folder=%s&index=%d&target_account=%s" hx-target="#read" hx-swap="innerHTML">Target</button></div><div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
|
||||||
html.EscapeString(title), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
|
html.EscapeString(title), urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(targetAccount), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTargetPane(account, folder, targetAccount string, index int, raw []byte) string {
|
func renderTargetPane(account, folder, targetAccount string, index int, raw []byte, note string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(renderTargetAccountSelect(account, folder, targetAccount, index))
|
b.WriteString(renderTargetAccountSelect(account, folder, targetAccount, index))
|
||||||
|
if note != "" {
|
||||||
|
class := "ok"
|
||||||
|
if strings.HasPrefix(note, "Fehler") {
|
||||||
|
class = "bad"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<div class="audit-note %s">%s</div>`, class, html.EscapeString(note))
|
||||||
|
}
|
||||||
if match, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok {
|
if match, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok {
|
||||||
renderTargetMatch(&b, match)
|
renderTargetMatch(&b, match)
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|
@ -220,10 +258,7 @@ func renderTargetMatch(b *strings.Builder, match forwardedMatch) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderInitialTargetPane() string {
|
func renderInitialTargetPane() string {
|
||||||
var b strings.Builder
|
return `<div class="pane-head">Quelle</div><div class="read-body"><div class="ef-empty">Nachricht waehlen.</div></div>`
|
||||||
b.WriteString(renderTargetAccountSelect("", "", "", -1))
|
|
||||||
b.WriteString(`<div class="read-body"><div class="ef-empty">Nachricht waehlen.</div></div>`)
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderReadContent(w io.Writer, subject, from, date, body string) {
|
func renderReadContent(w io.Writer, subject, from, date, body string) {
|
||||||
|
|
@ -399,9 +434,9 @@ func messageIDHeader(raw []byte) string {
|
||||||
func renderTargetAccountSelect(account, folder, selected string, index int) string {
|
func renderTargetAccountSelect(account, folder, selected string, index int) string {
|
||||||
options := targetAccountOptions()
|
options := targetAccountOptions()
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, `<div class="pane-head target-head"><span>Zielkopie</span><form class="target-select-form"`)
|
fmt.Fprintf(&b, `<div class="pane-head target-head"><span>Target</span><form class="target-select-form"`)
|
||||||
if account != "" && folder != "" && index >= 0 {
|
if account != "" && folder != "" && index >= 0 {
|
||||||
fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read-target" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`)
|
fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, `>
|
fmt.Fprintf(&b, `>
|
||||||
<input type="hidden" name="source" value="%s">
|
<input type="hidden" name="source" value="%s">
|
||||||
|
|
@ -416,10 +451,73 @@ func renderTargetAccountSelect(account, folder, selected string, index int) stri
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
|
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
|
||||||
}
|
}
|
||||||
b.WriteString(`</select></form></div>`)
|
fmt.Fprintf(&b, `</select></form><button class="btn secondary compact" type="button" hx-get="/view/message?source=%s&folder=%s&index=%d&target_account=%s" hx-target="#read" hx-swap="innerHTML">Quelle</button></div>`,
|
||||||
|
urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(selected))
|
||||||
|
if account != "" && folder != "" && index >= 0 {
|
||||||
|
fmt.Fprintf(&b, `<div class="drop-zone" data-drop-target="manual-copy" data-source="%s" data-folder="%s" data-index="%d">Mail hier ablegen fuer manuellen Umzug</div>`,
|
||||||
|
html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), index)
|
||||||
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendManualTargetCopy(account, folder, targetAccount string, raw []byte) (string, error) {
|
||||||
|
id := messageIDHeader(raw)
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("mail ohne Message-ID kann nicht sauber dedupliziert werden")
|
||||||
|
}
|
||||||
|
if _, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok {
|
||||||
|
return "Schon vorhanden: Zielkopie wurde per Message-ID gefunden.", nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
a, ok := accountByMboxDir(account)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("kein Konto zur mbox %s gefunden", account)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(targetAccount) != "" && !strings.EqualFold(strings.TrimSpace(a.DstUser), strings.TrimSpace(targetAccount)) {
|
||||||
|
candidates := accountsForTargetUser(targetAccount)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", fmt.Errorf("kein Zielkonto %s gefunden", targetAccount)
|
||||||
|
}
|
||||||
|
a = candidates[0]
|
||||||
|
}
|
||||||
|
dst, err := OpenIMAPTarget(a)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
targets, err := dst.Folders()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dstFolder := MapSourceToTarget(folder, nil, "/", dst.Delim(), targets, "")
|
||||||
|
if dstFolder == "" {
|
||||||
|
dstFolder = folder
|
||||||
|
}
|
||||||
|
if err := dst.EnsureFolder(dstFolder); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
msg := RawMessage{MessageID: id, Body: raw, InternalDate: messageDate(raw)}
|
||||||
|
if err := dst.Append(dstFolder, msg); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if a.ID != 0 {
|
||||||
|
_ = MarkCopied(a.ID, folder, id)
|
||||||
|
}
|
||||||
|
return "Manuell kopiert nach " + strings.TrimSpace(a.DstUser) + " / " + dstFolder + ".", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageDate(raw []byte) time.Time {
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if t, err := mail.ParseDate(msg.Header.Get("Date")); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
type viewerAccount struct {
|
type viewerAccount struct {
|
||||||
Name string
|
Name string
|
||||||
SourceLabel string
|
SourceLabel string
|
||||||
|
|
|
||||||
85
frontend-js/dist/app.js
vendored
85
frontend-js/dist/app.js
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
(function () {
|
(function () {
|
||||||
const key = "mail-graveyard-pane-widths";
|
const key = "mail-graveyard-pane-widths-v2";
|
||||||
const minWidths = [160, 220, 260, 260];
|
const minWidths = [160, 220, 300];
|
||||||
const handleWidth = 6;
|
const handleWidth = 6;
|
||||||
|
|
||||||
function panes(container) {
|
function panes(container) {
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
function clampWidths(widths, total) {
|
function clampWidths(widths, total) {
|
||||||
const next = widths.map((w, i) => Math.max(minWidths[i], Math.round(w || minWidths[i])));
|
const next = widths.map((w, i) => Math.max(minWidths[i], Math.round(w || minWidths[i])));
|
||||||
const max = Math.max(total - handleWidth * 3, minWidths.reduce((a, b) => a + b, 0));
|
const max = Math.max(total - handleWidth * (next.length - 1), minWidths.reduce((a, b) => a + b, 0));
|
||||||
const sum = next.reduce((a, b) => a + b, 0);
|
const sum = next.reduce((a, b) => a + b, 0);
|
||||||
if (sum > max) {
|
if (sum > max) {
|
||||||
let extra = sum - max;
|
let extra = sum - max;
|
||||||
|
|
@ -31,14 +31,14 @@
|
||||||
const total = container.getBoundingClientRect().width;
|
const total = container.getBoundingClientRect().width;
|
||||||
const w = clampWidths(widths, total);
|
const w = clampWidths(widths, total);
|
||||||
container.style.gridTemplateColumns =
|
container.style.gridTemplateColumns =
|
||||||
`${w[0]}px ${handleWidth}px ${w[1]}px ${handleWidth}px ${w[2]}px ${handleWidth}px ${w[3]}px`;
|
`${w[0]}px ${handleWidth}px ${w[1]}px ${handleWidth}px minmax(${w[2]}px, 1fr)`;
|
||||||
localStorage.setItem(key, JSON.stringify(w));
|
localStorage.setItem(key, JSON.stringify(w));
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore(container) {
|
function restore(container) {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(key) || "null");
|
const saved = JSON.parse(localStorage.getItem(key) || "null");
|
||||||
if (Array.isArray(saved) && saved.length === 4) {
|
if (Array.isArray(saved) && saved.length === 3) {
|
||||||
applyWidths(container, saved);
|
applyWidths(container, saved);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -90,4 +90,79 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".four-pane")));
|
window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".four-pane")));
|
||||||
|
window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".three-pane")));
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
function rowPayload(row) {
|
||||||
|
return {
|
||||||
|
source: row.dataset.source || "",
|
||||||
|
folder: row.dataset.folder || "",
|
||||||
|
index: row.dataset.index || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("dragstart", (event) => {
|
||||||
|
const row = event.target.closest(".msg-row[draggable='true']");
|
||||||
|
if (!row || !event.dataTransfer) return;
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData("application/json", JSON.stringify(rowPayload(row)));
|
||||||
|
event.dataTransfer.setData("text/plain", `${row.dataset.source || ""}/${row.dataset.folder || ""}#${row.dataset.index || ""}`);
|
||||||
|
row.classList.add("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("dragend", (event) => {
|
||||||
|
const row = event.target.closest(".msg-row");
|
||||||
|
if (row) row.classList.remove("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("dragover", (event) => {
|
||||||
|
const zone = event.target.closest("[data-drop-target='manual-copy']");
|
||||||
|
if (!zone) return;
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.add("drag-over");
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("dragleave", (event) => {
|
||||||
|
const zone = event.target.closest("[data-drop-target='manual-copy']");
|
||||||
|
if (zone) zone.classList.remove("drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("drop", async (event) => {
|
||||||
|
const zone = event.target.closest("[data-drop-target='manual-copy']");
|
||||||
|
if (!zone) return;
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.remove("drag-over");
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(event.dataTransfer.getData("application/json") || "{}");
|
||||||
|
} catch (_) {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
if (!payload.source) {
|
||||||
|
payload = {
|
||||||
|
source: zone.dataset.source || "",
|
||||||
|
folder: zone.dataset.folder || "",
|
||||||
|
index: zone.dataset.index || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = document.querySelector("#target-account-select");
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.set("source", payload.source);
|
||||||
|
form.set("folder", payload.folder);
|
||||||
|
form.set("index", payload.index);
|
||||||
|
form.set("target_account", select ? select.value : "");
|
||||||
|
zone.textContent = "Kopiere...";
|
||||||
|
|
||||||
|
const response = await fetch("/view/manual-copy", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
|
||||||
|
body: form.toString()
|
||||||
|
});
|
||||||
|
const html = await response.text();
|
||||||
|
document.querySelector("#read").innerHTML = html;
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
30
frontend-js/dist/style.css
vendored
30
frontend-js/dist/style.css
vendored
|
|
@ -48,14 +48,14 @@ body.ol2013 {
|
||||||
.tab:hover { background: var(--ol-blue-dark); color: #fff; }
|
.tab:hover { background: var(--ol-blue-dark); color: #fff; }
|
||||||
.tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; }
|
.tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; }
|
||||||
|
|
||||||
/* Vier-Spalten-Viewer */
|
/* Viewer */
|
||||||
.three-pane,
|
.three-pane,
|
||||||
.four-pane {
|
.four-pane {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0; /* erlaubt Scrollen in den Panes */
|
min-height: 0; /* erlaubt Scrollen in den Panes */
|
||||||
}
|
}
|
||||||
.three-pane { grid-template-columns: 220px 340px 1fr; }
|
.three-pane { grid-template-columns: 220px 6px 340px 6px minmax(300px, 1fr); }
|
||||||
.four-pane { grid-template-columns: 220px 6px 340px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr); }
|
.four-pane { grid-template-columns: 220px 6px 340px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr); }
|
||||||
.pane {
|
.pane {
|
||||||
border-right: 1px solid var(--ol-border);
|
border-right: 1px solid var(--ol-border);
|
||||||
|
|
@ -84,7 +84,7 @@ body.resizing { cursor: col-resize; user-select: none; }
|
||||||
}
|
}
|
||||||
.target-head {
|
.target-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +124,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
}
|
}
|
||||||
.msg-row:hover { background: #eef6fd; }
|
.msg-row:hover { background: #eef6fd; }
|
||||||
|
.msg-row.dragging { opacity: .55; }
|
||||||
.msg-row.active { background: var(--ol-sel); border-left: 3px solid var(--ol-blue); padding-left: 9px; }
|
.msg-row.active { background: var(--ol-sel); border-left: 3px solid var(--ol-blue); padding-left: 9px; }
|
||||||
.msg-from { font-weight: 600; }
|
.msg-from { font-weight: 600; }
|
||||||
.msg-subject { color: var(--ol-text); }
|
.msg-subject { color: var(--ol-text); }
|
||||||
|
|
@ -133,6 +134,12 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
/* Lesebereich */
|
/* Lesebereich */
|
||||||
.read-body { padding: 16px 20px; }
|
.read-body { padding: 16px 20px; }
|
||||||
|
.read-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
.read-head { border-bottom: 1px solid var(--ol-border); padding-bottom: 10px; margin-bottom: 12px; }
|
.read-head { border-bottom: 1px solid var(--ol-border); padding-bottom: 10px; margin-bottom: 12px; }
|
||||||
.read-subject { font-size: 17px; font-weight: 600; margin: 0 0 6px; }
|
.read-subject { font-size: 17px; font-weight: 600; margin: 0 0 6px; }
|
||||||
.read-meta { color: var(--ol-muted); font-size: 12px; }
|
.read-meta { color: var(--ol-muted); font-size: 12px; }
|
||||||
|
|
@ -145,6 +152,19 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
}
|
}
|
||||||
.ef-empty { color: var(--ol-muted); padding: 24px; }
|
.ef-empty { color: var(--ol-muted); padding: 24px; }
|
||||||
.audit-note { padding: 10px 16px; border-bottom: 1px solid var(--ol-border); font-size: 12px; }
|
.audit-note { padding: 10px 16px; border-bottom: 1px solid var(--ol-border); font-size: 12px; }
|
||||||
|
.drop-zone {
|
||||||
|
margin: 10px 16px 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--ol-sel-bd);
|
||||||
|
background: #f6fbff;
|
||||||
|
color: var(--ol-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.drop-zone.drag-over {
|
||||||
|
background: var(--ol-sel);
|
||||||
|
color: var(--ol-text);
|
||||||
|
border-color: var(--ol-blue);
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons / Formulare (flach, 2013er) */
|
/* Buttons / Formulare (flach, 2013er) */
|
||||||
.btn {
|
.btn {
|
||||||
|
|
@ -154,6 +174,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
.btn:hover { background: var(--ol-blue-dark); }
|
.btn:hover { background: var(--ol-blue-dark); }
|
||||||
.btn.secondary { background: #e1e1e1; color: var(--ol-text); border: 1px solid var(--ol-border); }
|
.btn.secondary { background: #e1e1e1; color: var(--ol-text); border: 1px solid var(--ol-border); }
|
||||||
.btn.secondary:hover { background: #d5d5d5; }
|
.btn.secondary:hover { background: #d5d5d5; }
|
||||||
|
.btn.compact { padding: 3px 9px; }
|
||||||
.input, select, textarea {
|
.input, select, textarea {
|
||||||
border: 1px solid #abadb3; padding: 5px 7px; font: inherit; width: 100%;
|
border: 1px solid #abadb3; padding: 5px 7px; font: inherit; width: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
@ -172,6 +193,9 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
.bad { color: #d13438; }
|
.bad { color: #d13438; }
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
.three-pane {
|
||||||
|
grid-template-columns: 200px 6px 320px 6px minmax(300px, 1fr);
|
||||||
|
}
|
||||||
.four-pane {
|
.four-pane {
|
||||||
grid-template-columns: 200px 6px 320px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr);
|
grid-template-columns: 200px 6px 320px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue