Build mailbox management page
This commit is contained in:
parent
cff776dc8c
commit
c87880c544
3 changed files with 420 additions and 12 deletions
|
|
@ -1,8 +1,14 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(mux *http.ServeMux) {
|
func RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
|
@ -44,9 +50,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
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>Mail-Graveyard</title>
|
<title>Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-2">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-3">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-2" defer></script></head>
|
<script src="/static/app.js?v=20260712-3" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
|
|
@ -77,11 +83,311 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
</body></html>`, active, renderInitialReadPane(readingPane))
|
</body></html>`, active, renderInitialReadPane(readingPane))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) ---
|
func accountsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accounts, err := ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editName := strings.TrimSpace(r.URL.Query().Get("edit"))
|
||||||
|
var edit Account
|
||||||
|
if editName != "" {
|
||||||
|
edit, err = GetAccount(editName)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderAccountsPage(w, accounts, edit, r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
|
||||||
|
}
|
||||||
|
|
||||||
func accountsHandler(w http.ResponseWriter, r *http.Request) {}
|
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {}
|
if r.Method != http.MethodPost {
|
||||||
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {}
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
func accountTestHandler(w http.ResponseWriter, r *http.Request) {}
|
return
|
||||||
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {}
|
}
|
||||||
func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {}
|
if err := r.ParseForm(); err != nil {
|
||||||
|
redirectAccounts(w, r, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a := accountFromForm(r)
|
||||||
|
if a.Name == "" || a.SrcHost == "" || a.SrcUser == "" || a.DstHost == "" || a.DstUser == "" {
|
||||||
|
redirectAccounts(w, r, a.Name, "Name, Quelle und Ziel sind Pflichtfelder.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing, err := GetAccount(a.Name); err == nil {
|
||||||
|
if a.SrcPass == "" {
|
||||||
|
a.SrcPass = existing.SrcPass
|
||||||
|
}
|
||||||
|
if a.DstPass == "" {
|
||||||
|
a.DstPass = existing.DstPass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := SaveAccount(a); err != nil {
|
||||||
|
redirectAccounts(w, r, a.Name, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAccounts(w, r, a.Name, "Postfach gespeichert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
redirectAccounts(w, r, "", "Kein Postfach gewaehlt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := DeleteAccount(name); err != nil {
|
||||||
|
redirectAccounts(w, r, name, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAccounts(w, r, "", "Postfach geloescht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
redirectAccounts(w, r, "", "Kein Postfach gewaehlt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := CheckAccount(name); err != nil {
|
||||||
|
redirectAccounts(w, r, name, "Test fehlgeschlagen: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectAccounts(w, r, name, "Quelle und Ziel erfolgreich getestet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
name = "all"
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := RunMigration(name, false, 0); err != nil {
|
||||||
|
fmt.Printf("migration %s failed: %v\n", name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
redirectAccounts(w, r, name, "Umzug gestartet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type job struct {
|
||||||
|
Account string
|
||||||
|
Started string
|
||||||
|
Finished sql.NullString
|
||||||
|
Total int
|
||||||
|
Done int
|
||||||
|
Errors int
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
var j job
|
||||||
|
err := DB.QueryRow(`SELECT a.name,j.started,j.finished,j.total,j.done,j.errors,j.state
|
||||||
|
FROM jobs j JOIN accounts a ON a.id=j.account_id
|
||||||
|
ORDER BY j.id DESC LIMIT 1`).Scan(&j.Account, &j.Started, &j.Finished, &j.Total, &j.Done, &j.Errors, &j.State)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
fmt.Fprint(w, "bereit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, `<span class="bad">%s</span>`, html.EscapeString(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := "laeuft"
|
||||||
|
if j.State != "" {
|
||||||
|
label = j.State
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `%s: %s %d/%d Fehler %d`, html.EscapeString(j.Account), html.EscapeString(label), j.Done, j.Total, j.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, msg, errMsg string) {
|
||||||
|
if edit.Name == "" {
|
||||||
|
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; 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">
|
||||||
|
<title>Postfach-Verwaltung - Mail-Graveyard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=20260712-3">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/app.js?v=20260712-3" defer></script></head>
|
||||||
|
<body class="ol2013">
|
||||||
|
<header class="ribbon">
|
||||||
|
<nav class="ribbon-tabs">
|
||||||
|
<a class="tab" href="/view"><span class="mail-logo" aria-hidden="true"></span><span>Archiv-Boxen</span></a>
|
||||||
|
<a class="tab" href="/view">Import/Export</a>
|
||||||
|
<a class="tab active" href="/accounts">Postfach-Verwaltung</a>
|
||||||
|
<a class="tab" href="/view">Mail-Transfer</a>
|
||||||
|
<a class="tab" href="/accounts">Einstellungen</a>
|
||||||
|
</nav>
|
||||||
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
|
</header>
|
||||||
|
<main class="accounts-page">`)
|
||||||
|
if msg != "" {
|
||||||
|
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Eingetragene Postfaecher</div>`)
|
||||||
|
renderAccountsTable(w, accounts, edit.Name)
|
||||||
|
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Postfach eintragen</div>`)
|
||||||
|
renderAccountForm(w, edit)
|
||||||
|
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>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName string) {
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
fmt.Fprint(w, `<div class="ef-empty">Noch keine Postfaecher eingetragen.</div>`)
|
||||||
|
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></th></tr></thead><tbody>`)
|
||||||
|
for _, a := range accounts {
|
||||||
|
activeClass := ""
|
||||||
|
if a.Name == editName {
|
||||||
|
activeClass = ` class="selected"`
|
||||||
|
}
|
||||||
|
status := "aktiv"
|
||||||
|
if !a.Active {
|
||||||
|
status = "inaktiv"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<tr%s><td><a href="/accounts?edit=%s">%s</a></td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td class="row-actions">
|
||||||
|
<form method="post" action="/accounts/test"><input type="hidden" name="name" value="%s"><button class="btn compact secondary" type="submit">Test</button></form>
|
||||||
|
<form method="post" action="/migrate/run"><input type="hidden" name="name" value="%s"><button class="btn compact secondary" type="submit">Umzug</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>`,
|
||||||
|
activeClass, urlQuery(a.Name), html.EscapeString(a.Name), html.EscapeString(a.SrcUser),
|
||||||
|
html.EscapeString(a.DstUser), html.EscapeString(a.MboxDir), status,
|
||||||
|
html.EscapeString(a.Name), html.EscapeString(a.Name), html.EscapeString(a.Name))
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `</tbody></table>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAccountForm(w http.ResponseWriter, a Account) {
|
||||||
|
fmt.Fprintf(w, `<form class="account-form" method="post" action="/accounts/save">
|
||||||
|
<div class="form-section">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="form-columns">
|
||||||
|
<fieldset><legend>Quelle</legend>
|
||||||
|
<label class="label">Server<input class="input" name="src_host" value="%s" required></label>
|
||||||
|
<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">Sicherheit%s</label>
|
||||||
|
</div>
|
||||||
|
<label class="label">Benutzer<input class="input" name="src_user" value="%s" required></label>
|
||||||
|
<label class="label">Passwort<input class="input" name="src_pass" type="password" autocomplete="new-password" placeholder="leer lassen = behalten"></label>
|
||||||
|
<label class="label">Protokoll%s</label>
|
||||||
|
<label class="check-row"><input type="checkbox" name="src_insecure" value="1" %s> Zertifikat nicht pruefen</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><legend>Ziel</legend>
|
||||||
|
<label class="label">Server<input class="input" name="dst_host" value="%s" required></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="label">Port<input class="input" name="dst_port" type="number" min="1" max="65535" value="%d"></label>
|
||||||
|
<label class="label">Sicherheit%s</label>
|
||||||
|
</div>
|
||||||
|
<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">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>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" type="submit">Speichern</button>
|
||||||
|
<a class="btn secondary" href="/accounts">Neu</a>
|
||||||
|
</div>
|
||||||
|
</form>`,
|
||||||
|
html.EscapeString(a.Name), checked(a.Active),
|
||||||
|
html.EscapeString(a.SrcHost), a.SrcPort, securitySelect("src_security", a.SrcSecurity),
|
||||||
|
html.EscapeString(a.SrcUser), protoSelect(a.SrcProto), checked(a.SrcInsecure),
|
||||||
|
html.EscapeString(a.DstHost), a.DstPort, securitySelect("dst_security", a.DstSecurity),
|
||||||
|
html.EscapeString(a.DstUser), html.EscapeString(a.MboxDir), checked(a.DstInsecure))
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountFromForm(r *http.Request) Account {
|
||||||
|
return Account{
|
||||||
|
Name: strings.TrimSpace(r.FormValue("name")),
|
||||||
|
SrcHost: strings.TrimSpace(r.FormValue("src_host")),
|
||||||
|
SrcPort: parsePort(r.FormValue("src_port")),
|
||||||
|
SrcSecurity: strings.TrimSpace(r.FormValue("src_security")),
|
||||||
|
SrcInsecure: r.FormValue("src_insecure") == "1",
|
||||||
|
SrcUser: strings.TrimSpace(r.FormValue("src_user")),
|
||||||
|
SrcPass: r.FormValue("src_pass"),
|
||||||
|
SrcProto: strings.TrimSpace(r.FormValue("src_proto")),
|
||||||
|
DstHost: strings.TrimSpace(r.FormValue("dst_host")),
|
||||||
|
DstPort: parsePort(r.FormValue("dst_port")),
|
||||||
|
DstSecurity: strings.TrimSpace(r.FormValue("dst_security")),
|
||||||
|
DstInsecure: r.FormValue("dst_insecure") == "1",
|
||||||
|
DstUser: strings.TrimSpace(r.FormValue("dst_user")),
|
||||||
|
DstPass: r.FormValue("dst_pass"),
|
||||||
|
MboxDir: strings.TrimSpace(r.FormValue("mbox_dir")),
|
||||||
|
Active: r.FormValue("active") == "1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func securitySelect(name, current string) string {
|
||||||
|
if current == "" {
|
||||||
|
current = "tls"
|
||||||
|
}
|
||||||
|
return selectHTML(name, current, []string{"tls", "starttls", "none"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoSelect(current string) string {
|
||||||
|
if current == "" {
|
||||||
|
current = "imap"
|
||||||
|
}
|
||||||
|
return selectHTML("src_proto", current, []string{"imap", "pop3"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectHTML(name, current string, values []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, `<select name="%s">`, name)
|
||||||
|
for _, v := range values {
|
||||||
|
sel := ""
|
||||||
|
if v == current {
|
||||||
|
sel = ` selected`
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, v, sel, v)
|
||||||
|
}
|
||||||
|
b.WriteString(`</select>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checked(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "checked"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePort(value string) int {
|
||||||
|
port, _ := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectAccounts(w http.ResponseWriter, r *http.Request, edit, msg string) {
|
||||||
|
key := "msg"
|
||||||
|
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "error") {
|
||||||
|
key = "err"
|
||||||
|
}
|
||||||
|
target := "/accounts?" + key + "=" + urlQuery(msg)
|
||||||
|
if edit != "" {
|
||||||
|
target += "&edit=" + urlQuery(edit)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlQuery(value string) string {
|
||||||
|
return url.QueryEscape(value)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,6 @@ func normalizeAccount(a *Account) {
|
||||||
if a.MboxDir == "" {
|
if a.MboxDir == "" {
|
||||||
a.MboxDir = a.Name
|
a.MboxDir = a.Name
|
||||||
}
|
}
|
||||||
if !a.Active {
|
|
||||||
a.Active = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolInt(v bool) int {
|
func boolInt(v bool) int {
|
||||||
|
|
|
||||||
105
frontend-js/dist/style.css
vendored
105
frontend-js/dist/style.css
vendored
|
|
@ -270,6 +270,105 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
|
||||||
.label { display: block; font-size: 12px; color: var(--ol-muted); margin-bottom: 3px; }
|
.label { display: block; font-size: 12px; color: var(--ol-muted); margin-bottom: 3px; }
|
||||||
|
|
||||||
|
/* Postfach-Verwaltung */
|
||||||
|
.accounts-page {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--ol-border);
|
||||||
|
background: #f8fbfd;
|
||||||
|
}
|
||||||
|
.account-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(520px, 1fr) minmax(460px, 560px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.account-list,
|
||||||
|
.account-form-panel {
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid var(--ol-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.account-form-panel { border-right: 0; }
|
||||||
|
.account-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.account-table th,
|
||||||
|
.account-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #ececec;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.account-table th {
|
||||||
|
color: var(--ol-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.account-table tr.selected { background: var(--ol-sel); }
|
||||||
|
.account-table a { color: var(--ol-blue-dark); font-weight: 600; text-decoration: none; }
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.row-actions form { margin: 0; }
|
||||||
|
.account-form {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.form-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.account-form fieldset {
|
||||||
|
border: 1px solid var(--ol-border);
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.account-form legend {
|
||||||
|
color: var(--ol-blue-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.check-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--ol-text);
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.check-row input { width: auto; }
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.form-actions a { text-decoration: none; }
|
||||||
|
.btn.danger { color: #8a1f11; }
|
||||||
|
|
||||||
/* Statusleiste */
|
/* Statusleiste */
|
||||||
.statusbar {
|
.statusbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
@ -302,4 +401,10 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
|
||||||
.pane-resizer { display: none; }
|
.pane-resizer { display: none; }
|
||||||
.pane-target { grid-column: auto; }
|
.pane-target { grid-column: auto; }
|
||||||
.searchbar { top: 30px; }
|
.searchbar { top: 30px; }
|
||||||
|
.account-layout,
|
||||||
|
.form-columns,
|
||||||
|
.form-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.account-table { min-width: 760px; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue