Add archive mailbox management
This commit is contained in:
parent
6ff29d5de7
commit
4616d725f2
2 changed files with 222 additions and 12 deletions
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
@ -20,8 +22,10 @@ func RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/accounts/save", accountSaveHandler) // TODO Codex
|
mux.HandleFunc("/accounts/save", accountSaveHandler) // TODO Codex
|
||||||
mux.HandleFunc("/accounts/delete", accountDeleteHandler) // TODO Codex
|
mux.HandleFunc("/accounts/delete", accountDeleteHandler) // TODO Codex
|
||||||
mux.HandleFunc("/accounts/test", accountTestHandler) // TODO Codex: Quelle+Ziel-Login pruefen
|
mux.HandleFunc("/accounts/test", accountTestHandler) // TODO Codex: Quelle+Ziel-Login pruefen
|
||||||
mux.HandleFunc("/migrate/run", migrateRunHandler) // TODO Codex: RunMigration im Hintergrund
|
mux.HandleFunc("/archives/create", archiveCreateHandler)
|
||||||
mux.HandleFunc("/migrate/status", migrateStatusHandler) // TODO Codex: jobs-Fortschritt (HTMX-Poll)
|
mux.HandleFunc("/archives/delete", archiveDeleteHandler)
|
||||||
|
mux.HandleFunc("/migrate/run", migrateRunHandler) // TODO Codex: RunMigration im Hintergrund
|
||||||
|
mux.HandleFunc("/migrate/status", migrateStatusHandler) // TODO Codex: jobs-Fortschritt (HTMX-Poll)
|
||||||
|
|
||||||
// Viewer (lokale mbox betrachten + weiterleiten)
|
// Viewer (lokale mbox betrachten + weiterleiten)
|
||||||
mux.HandleFunc("/view", viewerHandler)
|
mux.HandleFunc("/view", viewerHandler)
|
||||||
|
|
@ -164,6 +168,58 @@ func accountTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
redirectAccounts(w, r, name, "Quelle und Ziel erfolgreich getestet.")
|
redirectAccounts(w, r, name, "Quelle und Ziel erfolgreich getestet.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, err := safeArchiveName(r.FormValue("archive_name"))
|
||||||
|
if err != nil {
|
||||||
|
redirectAccounts(w, r, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(Cfg.MboxRoot, name), 0o700); err != nil {
|
||||||
|
redirectAccounts(w, r, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAccounts(w, r, "", "Archiv-Mailbox angelegt.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, err := safeArchiveName(r.FormValue("archive_name"))
|
||||||
|
if err != nil {
|
||||||
|
redirectAccounts(w, r, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if archiveInUse(name) {
|
||||||
|
redirectAccounts(w, r, "", "Archiv-Mailbox ist noch einem Postfach zugeordnet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(Cfg.MboxRoot, name)
|
||||||
|
entries, err := os.ReadDir(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
redirectAccounts(w, r, "", "Archiv-Mailbox existiert nicht.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
redirectAccounts(w, r, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
redirectAccounts(w, r, "", "Archiv-Mailbox ist nicht leer und wurde nicht entfernt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
redirectAccounts(w, r, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAccounts(w, r, "", "Leere Archiv-Mailbox entfernt.")
|
||||||
|
}
|
||||||
|
|
||||||
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
|
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
|
@ -214,13 +270,14 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
|
||||||
if edit.Name == "" {
|
if edit.Name == "" {
|
||||||
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
|
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
|
||||||
}
|
}
|
||||||
|
archives := archiveMailboxes(accounts)
|
||||||
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">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Postfach-Verwaltung - Mail-Graveyard</title>
|
<title>Postfach-Verwaltung - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-4">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-5">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-4" defer></script></head>
|
<script src="/static/app.js?v=20260712-5" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
|
|
@ -241,8 +298,10 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Eingetragene Postfaecher</div>`)
|
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Eingetragene Postfaecher</div>`)
|
||||||
renderAccountsTable(w, accounts, edit.Name)
|
renderAccountsTable(w, accounts, edit.Name)
|
||||||
|
fmt.Fprint(w, `<div class="pane-head">Archiv-mbox verwalten</div>`)
|
||||||
|
renderArchiveManager(w, archives, accounts)
|
||||||
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Postfach eintragen</div>`)
|
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Postfach eintragen</div>`)
|
||||||
renderAccountForm(w, edit)
|
renderAccountForm(w, edit, archives)
|
||||||
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
|
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,7 +310,7 @@ func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName str
|
||||||
fmt.Fprint(w, `<div class="ef-empty">Noch keine Postfaecher eingetragen.</div>`)
|
fmt.Fprint(w, `<div class="ef-empty">Noch keine Postfaecher eingetragen.</div>`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `<table class="account-table"><thead><tr><th>Name</th><th>Quelle</th><th>Ziel</th><th>Archiv</th><th>Status</th><th class="actions-col"></th></tr></thead><tbody>`)
|
fmt.Fprint(w, `<table class="account-table"><thead><tr><th>Name</th><th>Original</th><th>Archiv-mbox</th><th>Ziel</th><th>Status</th><th class="actions-col"></th></tr></thead><tbody>`)
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
activeClass := ""
|
activeClass := ""
|
||||||
if a.Name == editName {
|
if a.Name == editName {
|
||||||
|
|
@ -267,20 +326,42 @@ func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName str
|
||||||
<form method="post" action="/accounts/delete"><input type="hidden" name="name" value="%s"><button class="btn compact secondary danger" type="submit">Loeschen</button></form>
|
<form method="post" action="/accounts/delete"><input type="hidden" name="name" value="%s"><button class="btn compact secondary danger" type="submit">Loeschen</button></form>
|
||||||
</td></tr>`,
|
</td></tr>`,
|
||||||
activeClass, urlQuery(a.Name), html.EscapeString(a.Name), html.EscapeString(a.SrcUser),
|
activeClass, urlQuery(a.Name), html.EscapeString(a.Name), html.EscapeString(a.SrcUser),
|
||||||
html.EscapeString(a.DstUser), html.EscapeString(a.MboxDir), status,
|
html.EscapeString(accountMboxDir(a)), html.EscapeString(a.DstUser), status,
|
||||||
html.EscapeString(a.Name), html.EscapeString(a.Name), html.EscapeString(a.Name))
|
html.EscapeString(a.Name), html.EscapeString(a.Name), html.EscapeString(a.Name))
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `</tbody></table>`)
|
fmt.Fprint(w, `</tbody></table>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderAccountForm(w http.ResponseWriter, a Account) {
|
func renderArchiveManager(w http.ResponseWriter, archives []string, accounts []Account) {
|
||||||
|
fmt.Fprint(w, `<div class="archive-manager"><form class="archive-create" method="post" action="/archives/create">
|
||||||
|
<input class="input" name="archive_name" placeholder="neue Archiv-Mailbox">
|
||||||
|
<button class="btn compact" type="submit">Erstellen</button>
|
||||||
|
</form>`)
|
||||||
|
if len(archives) == 0 {
|
||||||
|
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div></div>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `<table class="archive-table"><thead><tr><th>Archiv-Mailbox</th><th>Zuordnung</th><th></th></tr></thead><tbody>`)
|
||||||
|
for _, archive := range archives {
|
||||||
|
uses := archiveUses(archive, accounts)
|
||||||
|
action := `<span class="muted">belegt</span>`
|
||||||
|
if uses == "" {
|
||||||
|
action = fmt.Sprintf(`<form method="post" action="/archives/delete"><input type="hidden" name="archive_name" value="%s"><button class="btn compact secondary danger" type="submit">Entfernen</button></form>`, html.EscapeString(archive))
|
||||||
|
uses = "frei"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td class="archive-action">%s</td></tr>`, html.EscapeString(archive), html.EscapeString(uses), action)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `</tbody></table></div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAccountForm(w http.ResponseWriter, a Account, archives []string) {
|
||||||
fmt.Fprintf(w, `<form class="account-form" method="post" action="/accounts/save">
|
fmt.Fprintf(w, `<form class="account-form" method="post" action="/accounts/save">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label class="label">Kontoname<input class="input" name="name" value="%s" required></label>
|
<label class="label">Kontoname<input class="input" name="name" value="%s" required></label>
|
||||||
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
|
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-columns">
|
<div class="form-columns">
|
||||||
<fieldset><legend>Quelle</legend>
|
<fieldset><legend>Original</legend>
|
||||||
<label class="label">Server<input class="input" name="src_host" value="%s" required></label>
|
<label class="label">Server<input class="input" name="src_host" value="%s" required></label>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label class="label">Port<input class="input" name="src_port" type="number" min="1" max="65535" value="%d"></label>
|
<label class="label">Port<input class="input" name="src_port" type="number" min="1" max="65535" value="%d"></label>
|
||||||
|
|
@ -291,6 +372,9 @@ func renderAccountForm(w http.ResponseWriter, a Account) {
|
||||||
<label class="label">Protokoll%s</label>
|
<label class="label">Protokoll%s</label>
|
||||||
<label class="check-row"><input type="checkbox" name="src_insecure" value="1" %s> Zertifikat nicht pruefen</label>
|
<label class="check-row"><input type="checkbox" name="src_insecure" value="1" %s> Zertifikat nicht pruefen</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset><legend>Archiv-mbox</legend>
|
||||||
|
<label class="label">Lokale Archiv-Mailbox%s</label>
|
||||||
|
</fieldset>
|
||||||
<fieldset><legend>Ziel</legend>
|
<fieldset><legend>Ziel</legend>
|
||||||
<label class="label">Server<input class="input" name="dst_host" value="%s" required></label>
|
<label class="label">Server<input class="input" name="dst_host" value="%s" required></label>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
|
|
@ -299,7 +383,6 @@ func renderAccountForm(w http.ResponseWriter, a Account) {
|
||||||
</div>
|
</div>
|
||||||
<label class="label">Benutzer<input class="input" name="dst_user" value="%s" required></label>
|
<label class="label">Benutzer<input class="input" name="dst_user" value="%s" required></label>
|
||||||
<label class="label">Passwort<input class="input" name="dst_pass" type="password" autocomplete="new-password" placeholder="leer lassen = behalten"></label>
|
<label class="label">Passwort<input class="input" name="dst_pass" type="password" autocomplete="new-password" placeholder="leer lassen = behalten"></label>
|
||||||
<label class="label">Archiv-Mailbox<input class="input" name="mbox_dir" value="%s" placeholder="leer = Kontoname"></label>
|
|
||||||
<label class="check-row"><input type="checkbox" name="dst_insecure" value="1" %s> Zertifikat nicht pruefen</label>
|
<label class="check-row"><input type="checkbox" name="dst_insecure" value="1" %s> Zertifikat nicht pruefen</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -311,8 +394,9 @@ func renderAccountForm(w http.ResponseWriter, a Account) {
|
||||||
html.EscapeString(a.Name), checked(a.Active),
|
html.EscapeString(a.Name), checked(a.Active),
|
||||||
html.EscapeString(a.SrcHost), a.SrcPort, securitySelect("src_security", a.SrcSecurity),
|
html.EscapeString(a.SrcHost), a.SrcPort, securitySelect("src_security", a.SrcSecurity),
|
||||||
html.EscapeString(a.SrcUser), protoSelect(a.SrcProto), checked(a.SrcInsecure),
|
html.EscapeString(a.SrcUser), protoSelect(a.SrcProto), checked(a.SrcInsecure),
|
||||||
|
archiveSelect(accountMboxDir(a), archives),
|
||||||
html.EscapeString(a.DstHost), a.DstPort, securitySelect("dst_security", a.DstSecurity),
|
html.EscapeString(a.DstHost), a.DstPort, securitySelect("dst_security", a.DstSecurity),
|
||||||
html.EscapeString(a.DstUser), html.EscapeString(a.MboxDir), checked(a.DstInsecure))
|
html.EscapeString(a.DstUser), checked(a.DstInsecure))
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountFromForm(r *http.Request) Account {
|
func accountFromForm(r *http.Request) Account {
|
||||||
|
|
@ -350,6 +434,34 @@ func protoSelect(current string) string {
|
||||||
return selectHTML("src_proto", current, []string{"imap", "pop3"})
|
return selectHTML("src_proto", current, []string{"imap", "pop3"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func archiveSelect(current string, archives []string) string {
|
||||||
|
values := append([]string{}, archives...)
|
||||||
|
found := false
|
||||||
|
for _, archive := range values {
|
||||||
|
if archive == current {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != "" && !found {
|
||||||
|
values = append([]string{current}, values...)
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return `<input class="input" name="mbox_dir" placeholder="erst Archiv-Mailbox erstellen">`
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<select name="mbox_dir">`)
|
||||||
|
for _, v := range values {
|
||||||
|
sel := ""
|
||||||
|
if v == current {
|
||||||
|
sel = ` selected`
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(v), sel, html.EscapeString(v))
|
||||||
|
}
|
||||||
|
b.WriteString(`</select>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func selectHTML(name, current string, values []string) string {
|
func selectHTML(name, current string, values []string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, `<select name="%s">`, name)
|
fmt.Fprintf(&b, `<select name="%s">`, name)
|
||||||
|
|
@ -376,6 +488,69 @@ func parsePort(value string) int {
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func archiveMailboxes(accounts []Account) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, a := range accounts {
|
||||||
|
name := accountMboxDir(a)
|
||||||
|
if name != "" {
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(Cfg.MboxRoot)
|
||||||
|
if err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
seen[entry.Name()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for name := range seen {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
sortStrings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveUses(archive string, accounts []Account) string {
|
||||||
|
var uses []string
|
||||||
|
for _, a := range accounts {
|
||||||
|
if accountMboxDir(a) == archive {
|
||||||
|
uses = append(uses, a.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(uses, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveInUse(archive string) bool {
|
||||||
|
accounts, err := ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return archiveUses(archive, accounts) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeArchiveName(value string) (string, error) {
|
||||||
|
name := strings.TrimSpace(value)
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("Name der Archiv-Mailbox fehlt.")
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." || strings.ContainsAny(name, `/\:`) || filepath.Clean(name) != name {
|
||||||
|
return "", fmt.Errorf("Ungueltiger Archiv-Mailbox-Name.")
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortStrings(values []string) {
|
||||||
|
for i := 0; i < len(values); i++ {
|
||||||
|
for j := i + 1; j < len(values); j++ {
|
||||||
|
if strings.ToLower(values[j]) < strings.ToLower(values[i]) {
|
||||||
|
values[i], values[j] = values[j], values[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func redirectAccounts(w http.ResponseWriter, r *http.Request, edit, msg string) {
|
func redirectAccounts(w http.ResponseWriter, r *http.Request, edit, msg string) {
|
||||||
key := "msg"
|
key := "msg"
|
||||||
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "error") {
|
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "error") {
|
||||||
|
|
|
||||||
37
frontend-js/dist/style.css
vendored
37
frontend-js/dist/style.css
vendored
|
|
@ -341,7 +341,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
}
|
}
|
||||||
.form-columns {
|
.form-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.account-form fieldset {
|
.account-form fieldset {
|
||||||
|
|
@ -375,6 +375,41 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
}
|
}
|
||||||
.form-actions a { text-decoration: none; }
|
.form-actions a { text-decoration: none; }
|
||||||
.btn.danger { color: #8a1f11; }
|
.btn.danger { color: #8a1f11; }
|
||||||
|
.archive-manager {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--ol-border);
|
||||||
|
}
|
||||||
|
.archive-create {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.archive-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.archive-table th,
|
||||||
|
.archive-table td {
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-bottom: 1px solid #ececec;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.archive-table th {
|
||||||
|
color: var(--ol-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.archive-action {
|
||||||
|
width: 90px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.archive-action form { margin: 0; }
|
||||||
|
.muted { color: var(--ol-muted); }
|
||||||
|
|
||||||
/* Statusleiste */
|
/* Statusleiste */
|
||||||
.statusbar {
|
.statusbar {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue