Kant CMS: Grundgerüst, visueller Editor, Mediathek
Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting. Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen, getrennten Entwürfen/Veröffentlichungen und optimistischer Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau, responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek, Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript. Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
82b07820bc
87 files changed
+14463
No files matched your search
@@ -0,0 +1,12 @@
|
||||
:root{font-family:Arial,Helvetica,sans-serif;color:#203b34;background:#f6f7f4;--ink:#203b34;--muted:#5a6861;--line:#dce2dc;--paper:#fff;--mint:#e5f0b6;--accent:#315b49;font-synthesis:none}
|
||||
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
||||
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
.media-launch{margin-top:12px;white-space:normal}.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}.media-heading h2{margin:0}.media-heading .eyebrow{margin-bottom:8px}.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}.media-tools label{margin-top:10px}.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}.media-card strong{font-size:12px;overflow-wrap:anywhere}.media-card span{font-size:11px;color:var(--muted)}.media-details{padding-left:22px;border-left:1px solid var(--line)}.media-details button,.media-details a{margin-top:12px;display:block}.media-details input{font-size:13px}.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}.media-footer label{margin:0;font-size:12px}#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}#media-status{font-size:12px;margin-block:15px}.media-dialog> .panel-hint{margin-top:20px}.media-details>p{font-size:12px}
|
||||
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,textarea,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.48}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,iframe:focus-visible{outline:3px solid #b55b12;outline-offset:3px}input,textarea,select{width:100%;border:1px solid #bcc9c0;border-radius:7px;background:#fff;color:var(--ink);padding:11px 12px;min-height:42px}textarea{resize:vertical;min-height:110px;line-height:1.6}label{display:block;margin:21px 0 8px;font-size:13px;font-weight:600}h1,h2,h3,p{margin-top:0}h1{font-size:38px;font-weight:500;letter-spacing:-1.5px;line-height:1.15}h2{font-size:18px;letter-spacing:-.4px}h3{font-size:16px}.muted{color:var(--muted);line-height:1.6}.eyebrow{font-size:10px;font-weight:700;letter-spacing:1.7px;color:var(--accent);margin-bottom:16px}.brand{display:inline-flex;align-items:center;font-size:25px;font-weight:700;text-decoration:none;letter-spacing:-1.3px;gap:3px}.brand>span:last-child{font-weight:400}.brand-icon{display:grid;place-items:center;width:33px;height:33px;background:var(--ink);color:var(--mint);border-radius:9px;margin-right:7px}.primary,.secondary,.subtle,.icon-button,.text-button,.block-actions button,.viewport-controls button{display:inline-flex;align-items:center;justify-content:center;gap:12px;border:1px solid var(--line);border-radius:7px;padding:11px 15px;text-decoration:none;font-size:12px;font-weight:600;background:white;color:var(--ink);white-space:nowrap;min-height:40px}.primary{background:var(--ink);color:white;border-color:var(--ink)}.primary:hover:not(:disabled){background:#37604b}.secondary:hover:not(:disabled){background:#f0f4ed}.subtle,.text-button{border-color:transparent;background:transparent}.text-button{padding:4px 8px;min-height:28px}.full-width{width:100%}.skip-link{position:fixed;top:-100px;left:16px;z-index:100;background:var(--ink);color:white;padding:15px}.skip-link:focus{top:10px}.notice,.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}.error{background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7}.login-body{min-height:100vh;display:grid;place-items:center;background:radial-gradient(ellipse at top left,#e5f0b6,transparent 65%),#f6f7f4;padding:30px}.login-card{width:100%;max-width:450px;padding:44px;background:white;border:1px solid var(--line);border-radius:18px;box-shadow:0 20px 60px #203b3408}.login-card .brand{margin-bottom:44px}.login-card h1{font-size:31px}.login-card .primary{margin-top:26px}.quiet-link{display:block;font-size:12px;text-align:center;margin-top:25px}.admin-header{padding:24px 42px;background:white;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:20px}.header-actions{display:flex;align-items:center;gap:24px;font-size:12px}.user-name{padding-left:24px;border-left:1px solid var(--line)}.pages-main{max-width:1120px;margin:74px auto;padding:0 30px}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:50px;margin-bottom:20px}.section-heading h2{margin:0}.section-heading>span{font-size:12px}.count{font-size:12px;background:#e5ebdf;padding:4px 8px;border-radius:6px;margin-left:8px}.page-list{display:grid;gap:13px}.page-card{display:flex;gap:20px;align-items:center;background:white;padding:24px;border:1px solid var(--line);border-radius:10px}.page-icon{font-size:25px;width:44px;height:50px;background:#f0f4e9;border-radius:8px;display:grid;place-items:center}.page-info{flex:1;min-width:0}.page-info h3{margin:0 0 9px}.page-info h3 a{text-decoration:none}.page-info p{font-size:12px;color:var(--muted);margin:0}.badge{font-size:10px;letter-spacing:.2px;background:#fff0d5;color:#78551d;padding:7px 10px;border-radius:20px;white-space:nowrap}.published{background:#edf3dc;color:#315b49}.footnote{font-size:11px;color:var(--muted);margin-top:27px}.empty-state{padding:35px;border:1px dashed var(--line);border-radius:10px}.editor-body{overflow:hidden}#editor{height:100dvh;display:flex;flex-direction:column}.editor-header{display:flex;align-items:center;gap:20px;min-height:76px;padding:13px 20px;border-bottom:1px solid var(--line);background:white;z-index:1}.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.editor-page-name>span{font-size:10px;color:var(--muted)}.history-actions,.save-actions{display:flex;align-items:center;gap:8px}.icon-button{font-size:21px;min-width:36px;padding:3px 9px}.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}Line truncated
|
||||
@media(max-width:1150px){.editor-layout{grid-template-columns:215px minmax(200px,1fr) 240px}.editor-header{gap:12px;padding-inline:14px}.save-actions>span{display:none}.panel-section{padding:18px 14px}.canvas-scroll{padding:12px}.canvas-label{display:none}}
|
||||
@media(max-width:850px){.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}.editor-header{flex-wrap:wrap}.editor-page-name{min-width:180px}.save-actions{width:100%;justify-content:flex-end}.save-actions>span{display:block;margin-right:auto}.editor-layout{display:flex;flex-wrap:wrap}.canvas-panel{order:0;width:100%;height:680px}.left-panel{order:1;width:45%;max-height:650px}.right-panel{order:2;width:55%;max-height:650px}.canvas-label{display:block}.admin-header{padding:20px}.header-actions{gap:12px}.header-actions .user-name{display:none}.page-card{flex-wrap:wrap;padding:18px;gap:14px}.page-info{min-width:180px}.pages-main{margin:40px auto;padding:0 20px}.section-heading{align-items:flex-start;flex-direction:column}.login-card{padding:30px}.login-body{padding:20px}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
|
||||
@media(prefers-reduced-motion:no-preference){button,a{transition:background-color .12s,border-color .12s}}
|
||||
.left-panel{display:flex;flex-direction:column;overflow:hidden}.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}.structure-section .block-actions{flex-shrink:0;background:white}.structure-section .panel-hint{margin:8px 0}.search-label{font-size:11px;margin:12px 0 6px}#tree-search{font-size:11px;padding:9px;min-height:36px}.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}.tree-tools .text-button{font-size:10px;padding:5px}.structure-list li{position:relative}.tree-item{padding-left:23px}.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}.preset-heading{font-size:12px;margin:22px 0 8px}.preset-library{display:grid;gap:6px}.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}.preset-library button:hover:not(:disabled){border-color:var(--accent)}.preset-library span{font-size:18px}.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}.design-reset{margin-top:24px;white-space:normal;width:100%}.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}.breadcrumbs .text-button{font-size:10px;white-space:normal}.breadcrumbs button+button:before{content:'›';color:var(--muted)}.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}.canvas-scroll{display:block}.canvas-scroll #preview-stage{position:relative;margin:0 auto}#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}.right-panel select{padding-inline:7px;text-overflow:ellipsis}.right-panel .panel-section{min-width:0}
|
||||
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
|
||||
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"align": {
|
||||
"label": "Ausrichtung",
|
||||
"types": [
|
||||
"section",
|
||||
"row",
|
||||
"column",
|
||||
"heading",
|
||||
"text"
|
||||
],
|
||||
"values": {
|
||||
"left": [
|
||||
"Links",
|
||||
"text-align:left;margin-inline:0"
|
||||
],
|
||||
"center": [
|
||||
"Mittig",
|
||||
"text-align:center;margin-inline:auto"
|
||||
],
|
||||
"right": [
|
||||
"Rechts",
|
||||
"text-align:right;margin-inline:auto 0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fontSize": {
|
||||
"label": "Schriftgröße",
|
||||
"types": [
|
||||
"heading",
|
||||
"text",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"small": [
|
||||
"Klein · 14 px",
|
||||
"font-size:14px"
|
||||
],
|
||||
"body": [
|
||||
"Fließtext · 18 px",
|
||||
"font-size:18px"
|
||||
],
|
||||
"large": [
|
||||
"Groß · 24 px",
|
||||
"font-size:24px"
|
||||
],
|
||||
"title": [
|
||||
"Titel · 36 px",
|
||||
"font-size:36px"
|
||||
],
|
||||
"display": [
|
||||
"Display · 52 px",
|
||||
"font-size:52px"
|
||||
],
|
||||
"hero": [
|
||||
"Hero · 72 px",
|
||||
"font-size:72px"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fontWeight": {
|
||||
"label": "Schriftstärke",
|
||||
"types": [
|
||||
"heading",
|
||||
"text",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"regular": [
|
||||
"Normal",
|
||||
"font-weight:400"
|
||||
],
|
||||
"medium": [
|
||||
"Mittel",
|
||||
"font-weight:500"
|
||||
],
|
||||
"bold": [
|
||||
"Fett",
|
||||
"font-weight:700"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lineHeight": {
|
||||
"label": "Zeilenhöhe",
|
||||
"types": [
|
||||
"heading",
|
||||
"text",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"tight": [
|
||||
"Kompakt · 1,15",
|
||||
"line-height:1.15"
|
||||
],
|
||||
"normal": [
|
||||
"Normal · 1,5",
|
||||
"line-height:1.5"
|
||||
],
|
||||
"relaxed": [
|
||||
"Luftig · 1,8",
|
||||
"line-height:1.8"
|
||||
]
|
||||
}
|
||||
},
|
||||
"paddingY": {
|
||||
"label": "Innenabstand oben / unten",
|
||||
"types": [
|
||||
"section",
|
||||
"column"
|
||||
],
|
||||
"values": {
|
||||
"none": [
|
||||
"Keiner",
|
||||
"padding-block:0"
|
||||
],
|
||||
"small": [
|
||||
"Klein · 16 px",
|
||||
"padding-block:16px"
|
||||
],
|
||||
"medium": [
|
||||
"Mittel · 32 px",
|
||||
"padding-block:32px"
|
||||
],
|
||||
"large": [
|
||||
"Groß · 64 px",
|
||||
"padding-block:64px"
|
||||
],
|
||||
"huge": [
|
||||
"Extra groß · 96 px",
|
||||
"padding-block:96px"
|
||||
]
|
||||
}
|
||||
},
|
||||
"paddingX": {
|
||||
"label": "Innenabstand links / rechts",
|
||||
"types": [
|
||||
"column"
|
||||
],
|
||||
"values": {
|
||||
"none": [
|
||||
"Keiner",
|
||||
"padding-inline:0"
|
||||
],
|
||||
"small": [
|
||||
"Klein · 16 px",
|
||||
"padding-inline:16px"
|
||||
],
|
||||
"medium": [
|
||||
"Mittel · 32 px",
|
||||
"padding-inline:32px"
|
||||
],
|
||||
"large": [
|
||||
"Groß · 64 px",
|
||||
"padding-inline:64px"
|
||||
]
|
||||
}
|
||||
},
|
||||
"marginTop": {
|
||||
"label": "Außenabstand oben",
|
||||
"types": [
|
||||
"section",
|
||||
"row",
|
||||
"column",
|
||||
"heading",
|
||||
"text",
|
||||
"image",
|
||||
"button",
|
||||
"gallery"
|
||||
],
|
||||
"values": {
|
||||
"none": [
|
||||
"Keiner",
|
||||
"margin-top:0"
|
||||
],
|
||||
"small": [
|
||||
"Klein · 8 px",
|
||||
"margin-top:8px"
|
||||
],
|
||||
"medium": [
|
||||
"Mittel · 24 px",
|
||||
"margin-top:24px"
|
||||
],
|
||||
"large": [
|
||||
"Groß · 48 px",
|
||||
"margin-top:48px"
|
||||
]
|
||||
}
|
||||
},
|
||||
"marginBottom": {
|
||||
"label": "Außenabstand unten",
|
||||
"types": [
|
||||
"section",
|
||||
"row",
|
||||
"column",
|
||||
"heading",
|
||||
"text",
|
||||
"image",
|
||||
"button",
|
||||
"gallery"
|
||||
],
|
||||
"values": {
|
||||
"none": [
|
||||
"Keiner",
|
||||
"margin-bottom:0"
|
||||
],
|
||||
"small": [
|
||||
"Klein · 8 px",
|
||||
"margin-bottom:8px"
|
||||
],
|
||||
"medium": [
|
||||
"Mittel · 24 px",
|
||||
"margin-bottom:24px"
|
||||
],
|
||||
"large": [
|
||||
"Groß · 48 px",
|
||||
"margin-bottom:48px"
|
||||
]
|
||||
}
|
||||
},
|
||||
"gap": {
|
||||
"label": "Spaltenabstand",
|
||||
"types": [
|
||||
"row",
|
||||
"gallery"
|
||||
],
|
||||
"values": {
|
||||
"none": [
|
||||
"Keiner",
|
||||
"gap:0"
|
||||
],
|
||||
"small": [
|
||||
"Klein · 12 px",
|
||||
"gap:12px"
|
||||
],
|
||||
"medium": [
|
||||
"Mittel · 24 px",
|
||||
"gap:24px"
|
||||
],
|
||||
"large": [
|
||||
"Groß · 48 px",
|
||||
"gap:48px"
|
||||
]
|
||||
}
|
||||
},
|
||||
"columns": {
|
||||
"label": "Spaltenaufteilung",
|
||||
"types": [
|
||||
"row",
|
||||
"gallery"
|
||||
],
|
||||
"values": {
|
||||
"auto": [
|
||||
"Automatisch umbrechen",
|
||||
"grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))"
|
||||
],
|
||||
"one": [
|
||||
"1 Spalte",
|
||||
"grid-template-columns:minmax(0,1fr)"
|
||||
],
|
||||
"two": [
|
||||
"2 gleiche Spalten",
|
||||
"grid-template-columns:repeat(2,minmax(0,1fr))"
|
||||
],
|
||||
"three": [
|
||||
"3 gleiche Spalten",
|
||||
"grid-template-columns:repeat(3,minmax(0,1fr))"
|
||||
],
|
||||
"four": [
|
||||
"4 gleiche Spalten",
|
||||
"grid-template-columns:repeat(4,minmax(0,1fr))"
|
||||
]
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"label": "Ecken",
|
||||
"types": [
|
||||
"column",
|
||||
"image",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"none": [
|
||||
"Gerade",
|
||||
"border-radius:0"
|
||||
],
|
||||
"small": [
|
||||
"Leicht · 8 px",
|
||||
"border-radius:8px"
|
||||
],
|
||||
"large": [
|
||||
"Rund · 24 px",
|
||||
"border-radius:24px"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Generated from design-tokens.json by npm run build:design. */
|
||||
#main .d-desktop-align-left{text-align:left;margin-inline:0}
|
||||
#main .d-desktop-align-center{text-align:center;margin-inline:auto}
|
||||
#main .d-desktop-align-right{text-align:right;margin-inline:auto 0}
|
||||
#main .d-desktop-fontSize-small{font-size:14px}
|
||||
#main .d-desktop-fontSize-body{font-size:18px}
|
||||
#main .d-desktop-fontSize-large{font-size:24px}
|
||||
#main .d-desktop-fontSize-title{font-size:36px}
|
||||
#main .d-desktop-fontSize-display{font-size:52px}
|
||||
#main .d-desktop-fontSize-hero{font-size:72px}
|
||||
#main .d-desktop-fontWeight-regular{font-weight:400}
|
||||
#main .d-desktop-fontWeight-medium{font-weight:500}
|
||||
#main .d-desktop-fontWeight-bold{font-weight:700}
|
||||
#main .d-desktop-lineHeight-tight{line-height:1.15}
|
||||
#main .d-desktop-lineHeight-normal{line-height:1.5}
|
||||
#main .d-desktop-lineHeight-relaxed{line-height:1.8}
|
||||
#main .d-desktop-paddingY-none{padding-block:0}
|
||||
#main .d-desktop-paddingY-small{padding-block:16px}
|
||||
#main .d-desktop-paddingY-medium{padding-block:32px}
|
||||
#main .d-desktop-paddingY-large{padding-block:64px}
|
||||
#main .d-desktop-paddingY-huge{padding-block:96px}
|
||||
#main .d-desktop-paddingX-none{padding-inline:0}
|
||||
#main .d-desktop-paddingX-small{padding-inline:16px}
|
||||
#main .d-desktop-paddingX-medium{padding-inline:32px}
|
||||
#main .d-desktop-paddingX-large{padding-inline:64px}
|
||||
#main .d-desktop-marginTop-none{margin-top:0}
|
||||
#main .d-desktop-marginTop-small{margin-top:8px}
|
||||
#main .d-desktop-marginTop-medium{margin-top:24px}
|
||||
#main .d-desktop-marginTop-large{margin-top:48px}
|
||||
#main .d-desktop-marginBottom-none{margin-bottom:0}
|
||||
#main .d-desktop-marginBottom-small{margin-bottom:8px}
|
||||
#main .d-desktop-marginBottom-medium{margin-bottom:24px}
|
||||
#main .d-desktop-marginBottom-large{margin-bottom:48px}
|
||||
#main .d-desktop-gap-none{gap:0}
|
||||
#main .d-desktop-gap-small{gap:12px}
|
||||
#main .d-desktop-gap-medium{gap:24px}
|
||||
#main .d-desktop-gap-large{gap:48px}
|
||||
#main .d-desktop-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
#main .d-desktop-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
#main .d-desktop-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#main .d-desktop-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#main .d-desktop-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
#main .d-desktop-radius-none{border-radius:0}
|
||||
#main .d-desktop-radius-small{border-radius:8px}
|
||||
#main .d-desktop-radius-large{border-radius:24px}
|
||||
@media(max-width:1024px){
|
||||
#main .d-tablet-align-left{text-align:left;margin-inline:0}
|
||||
#main .d-tablet-align-center{text-align:center;margin-inline:auto}
|
||||
#main .d-tablet-align-right{text-align:right;margin-inline:auto 0}
|
||||
#main .d-tablet-fontSize-small{font-size:14px}
|
||||
#main .d-tablet-fontSize-body{font-size:18px}
|
||||
#main .d-tablet-fontSize-large{font-size:24px}
|
||||
#main .d-tablet-fontSize-title{font-size:36px}
|
||||
#main .d-tablet-fontSize-display{font-size:52px}
|
||||
#main .d-tablet-fontSize-hero{font-size:72px}
|
||||
#main .d-tablet-fontWeight-regular{font-weight:400}
|
||||
#main .d-tablet-fontWeight-medium{font-weight:500}
|
||||
#main .d-tablet-fontWeight-bold{font-weight:700}
|
||||
#main .d-tablet-lineHeight-tight{line-height:1.15}
|
||||
#main .d-tablet-lineHeight-normal{line-height:1.5}
|
||||
#main .d-tablet-lineHeight-relaxed{line-height:1.8}
|
||||
#main .d-tablet-paddingY-none{padding-block:0}
|
||||
#main .d-tablet-paddingY-small{padding-block:16px}
|
||||
#main .d-tablet-paddingY-medium{padding-block:32px}
|
||||
#main .d-tablet-paddingY-large{padding-block:64px}
|
||||
#main .d-tablet-paddingY-huge{padding-block:96px}
|
||||
#main .d-tablet-paddingX-none{padding-inline:0}
|
||||
#main .d-tablet-paddingX-small{padding-inline:16px}
|
||||
#main .d-tablet-paddingX-medium{padding-inline:32px}
|
||||
#main .d-tablet-paddingX-large{padding-inline:64px}
|
||||
#main .d-tablet-marginTop-none{margin-top:0}
|
||||
#main .d-tablet-marginTop-small{margin-top:8px}
|
||||
#main .d-tablet-marginTop-medium{margin-top:24px}
|
||||
#main .d-tablet-marginTop-large{margin-top:48px}
|
||||
#main .d-tablet-marginBottom-none{margin-bottom:0}
|
||||
#main .d-tablet-marginBottom-small{margin-bottom:8px}
|
||||
#main .d-tablet-marginBottom-medium{margin-bottom:24px}
|
||||
#main .d-tablet-marginBottom-large{margin-bottom:48px}
|
||||
#main .d-tablet-gap-none{gap:0}
|
||||
#main .d-tablet-gap-small{gap:12px}
|
||||
#main .d-tablet-gap-medium{gap:24px}
|
||||
#main .d-tablet-gap-large{gap:48px}
|
||||
#main .d-tablet-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
#main .d-tablet-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
#main .d-tablet-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#main .d-tablet-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#main .d-tablet-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
#main .d-tablet-radius-none{border-radius:0}
|
||||
#main .d-tablet-radius-small{border-radius:8px}
|
||||
#main .d-tablet-radius-large{border-radius:24px}
|
||||
}
|
||||
@media(max-width:650px){
|
||||
#main .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||
#main .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||
#main .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||
#main .d-mobile-fontSize-small{font-size:14px}
|
||||
#main .d-mobile-fontSize-body{font-size:18px}
|
||||
#main .d-mobile-fontSize-large{font-size:24px}
|
||||
#main .d-mobile-fontSize-title{font-size:36px}
|
||||
#main .d-mobile-fontSize-display{font-size:52px}
|
||||
#main .d-mobile-fontSize-hero{font-size:72px}
|
||||
#main .d-mobile-fontWeight-regular{font-weight:400}
|
||||
#main .d-mobile-fontWeight-medium{font-weight:500}
|
||||
#main .d-mobile-fontWeight-bold{font-weight:700}
|
||||
#main .d-mobile-lineHeight-tight{line-height:1.15}
|
||||
#main .d-mobile-lineHeight-normal{line-height:1.5}
|
||||
#main .d-mobile-lineHeight-relaxed{line-height:1.8}
|
||||
#main .d-mobile-paddingY-none{padding-block:0}
|
||||
#main .d-mobile-paddingY-small{padding-block:16px}
|
||||
#main .d-mobile-paddingY-medium{padding-block:32px}
|
||||
#main .d-mobile-paddingY-large{padding-block:64px}
|
||||
#main .d-mobile-paddingY-huge{padding-block:96px}
|
||||
#main .d-mobile-paddingX-none{padding-inline:0}
|
||||
#main .d-mobile-paddingX-small{padding-inline:16px}
|
||||
#main .d-mobile-paddingX-medium{padding-inline:32px}
|
||||
#main .d-mobile-paddingX-large{padding-inline:64px}
|
||||
#main .d-mobile-marginTop-none{margin-top:0}
|
||||
#main .d-mobile-marginTop-small{margin-top:8px}
|
||||
#main .d-mobile-marginTop-medium{margin-top:24px}
|
||||
#main .d-mobile-marginTop-large{margin-top:48px}
|
||||
#main .d-mobile-marginBottom-none{margin-bottom:0}
|
||||
#main .d-mobile-marginBottom-small{margin-bottom:8px}
|
||||
#main .d-mobile-marginBottom-medium{margin-bottom:24px}
|
||||
#main .d-mobile-marginBottom-large{margin-bottom:48px}
|
||||
#main .d-mobile-gap-none{gap:0}
|
||||
#main .d-mobile-gap-small{gap:12px}
|
||||
#main .d-mobile-gap-medium{gap:24px}
|
||||
#main .d-mobile-gap-large{gap:48px}
|
||||
#main .d-mobile-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||
#main .d-mobile-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||
#main .d-mobile-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#main .d-mobile-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#main .d-mobile-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
#main .d-mobile-radius-none{border-radius:0}
|
||||
#main .d-mobile-radius-small{border-radius:8px}
|
||||
#main .d-mobile-radius-large{border-radius:24px}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦' };
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery'].includes(block?.type);
|
||||
export const canContain = (parent, block) => isContainer(parent) && (parent.type !== 'gallery' || block.type === 'image');
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
export function locate(blocks, id, parent = null) {
|
||||
for (let index = 0; index < blocks.length; index++) {
|
||||
const block = blocks[index];
|
||||
if (block.id === id) return { block, siblings: blocks, index, parent };
|
||||
const child = locate(block.children || [], id, block);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createBlock(type) {
|
||||
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
|
||||
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
|
||||
if (isContainer(block)) block.children = [];
|
||||
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
|
||||
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
|
||||
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
if (!target) document.blocks.push(block);
|
||||
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
|
||||
else target.siblings.splice(target.index + 1, 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
export function duplicateBlock(document, selected) {
|
||||
const target = locate(document.blocks, selected);
|
||||
if (!target) return null;
|
||||
const copy = clone(target.block);
|
||||
const rekey = block => {
|
||||
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
|
||||
(block.children || []).forEach(rekey);
|
||||
};
|
||||
rekey(copy);
|
||||
target.siblings.splice(target.index + 1, 0, copy);
|
||||
return copy.id;
|
||||
}
|
||||
|
||||
export function moveBlock(document, selected, targetId, inside = false) {
|
||||
const source = locate(document.blocks, selected);
|
||||
const target = locate(document.blocks, targetId);
|
||||
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
||||
if (inside && !isContainer(target.block)) return false;
|
||||
if (inside && !canContain(target.block, source.block)) return false;
|
||||
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
||||
source.siblings.splice(source.index, 1);
|
||||
const destination = locate(document.blocks, targetId);
|
||||
if (inside) (destination.block.children ??= []).push(source.block);
|
||||
else destination.siblings.splice(destination.index, 0, source.block);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3].includes(document.schemaVersion) || ![2, 3].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
|
||||
export function effectiveDesign(block, device, key) {
|
||||
let value, source;
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (block.layout?.design?.[name]?.[key] !== undefined) {
|
||||
value = block.layout.design[name][key]; source = name;
|
||||
}
|
||||
if (name === device) break;
|
||||
}
|
||||
return { value, source };
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
if (value === '') {
|
||||
const design = block.layout?.design;
|
||||
if (!design?.[device] || !Object.hasOwn(design[device], key)) return;
|
||||
delete design[device][key];
|
||||
if (!Object.keys(design[device]).length) delete design[device];
|
||||
if (!Object.keys(design).length) delete block.layout.design;
|
||||
} else {
|
||||
upgradeDocument(document);
|
||||
((block.layout ??= {}).design ??= {})[device] ??= {};
|
||||
block.layout.design[device][key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function blockPath(blocks, id) {
|
||||
const found = locate(blocks, id);
|
||||
return found ? [...(found.parent ? blockPath(blocks, found.parent.id) : []), found.block] : [];
|
||||
}
|
||||
|
||||
export function setEffect(document, id, key, value) {
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
upgradeDocument(document, 3);
|
||||
((block.layout ??= {}).effects ??= {})[key] = value;
|
||||
}
|
||||
|
||||
export function mediaProps(media) {
|
||||
const largest = media.variants.at(-1);
|
||||
return { mediaId: media.id, src: largest.src, width: largest.width, height: largest.height, alt: media.decorative ? '' : media.alt, decorative: Boolean(media.decorative), caption: '', sources: media.variants.map(({ src, width }) => ({ src, width })) };
|
||||
}
|
||||
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
}
|
||||
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
||||
return parent.id;
|
||||
}
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
||||
const section = createBlock('section');
|
||||
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
|
||||
const paragraph = text => { const block = createBlock('text'); block.props.text = text; return block; };
|
||||
section.layout = { tone: name === 'cta' ? 'ink' : 'paper', width: name === 'columns' ? 'normal' : 'narrow' };
|
||||
section.children = [heading(name === 'cta' ? 'Lass uns gemeinsam starten.' : name === 'columns' ? 'Was wir für dich tun.' : 'Raum für deine nächste Idee.'), paragraph('Erzähle hier, was dein Angebot besonders macht und welchen Nutzen deine Besucher davon haben.')];
|
||||
if (name === 'columns') {
|
||||
const row = createBlock('row');
|
||||
row.layout = { design: { desktop: { columns: 'two', gap: 'medium' }, mobile: { columns: 'one' } } };
|
||||
row.children = ['Persönlich beraten', 'Gemeinsam umsetzen'].map(text => {
|
||||
const column = createBlock('column');
|
||||
const title = heading(text); title.props.level = 3;
|
||||
column.children = [title, paragraph('Beschreibe dein Angebot in wenigen verständlichen Sätzen.')];
|
||||
return column;
|
||||
});
|
||||
section.children.push(row);
|
||||
upgradeDocument(document);
|
||||
} else section.children.push(createBlock('button'));
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
||||
document.blocks.splice(index, 0, section);
|
||||
return section.id;
|
||||
}
|
||||
|
||||
export class EditorHistory {
|
||||
constructor(document) { this.reset(document); }
|
||||
reset(document) {
|
||||
this.document = clone(document);
|
||||
this.past = [];
|
||||
this.future = [];
|
||||
this.mergeKey = null;
|
||||
this.saved = JSON.stringify(document);
|
||||
}
|
||||
change(mutator, mergeKey = null) {
|
||||
const next = clone(this.document);
|
||||
const result = mutator(next);
|
||||
if (JSON.stringify(next) === JSON.stringify(this.document)) return result;
|
||||
if (!mergeKey || this.mergeKey !== mergeKey) this.past.push(this.document);
|
||||
if (this.past.length > 100) this.past.shift();
|
||||
this.document = next;
|
||||
this.future = [];
|
||||
this.mergeKey = mergeKey;
|
||||
return result;
|
||||
}
|
||||
undo() {
|
||||
if (!this.past.length) return;
|
||||
this.future.push(this.document);
|
||||
this.document = this.past.pop();
|
||||
this.mergeKey = null;
|
||||
}
|
||||
redo() {
|
||||
if (!this.future.length) return;
|
||||
this.past.push(this.document);
|
||||
this.document = this.future.pop();
|
||||
this.mergeKey = null;
|
||||
}
|
||||
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
|
||||
get dirty() { return JSON.stringify(this.document) !== this.saved; }
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { EditorHistory, labels, icons, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
const root = document.querySelector('#editor');
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
|
||||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens;
|
||||
const collapsed = new Set();
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
$('#message').textContent = message;
|
||||
$('#message').hidden = !message;
|
||||
$('#recovery').hidden = !recover;
|
||||
}
|
||||
|
||||
async function request(path, body, signal) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(api + path, body === undefined ? { credentials: 'same-origin' } : {
|
||||
method: 'POST', credentials: 'same-origin', signal,
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
throw new Error('Die Verbindung ist unterbrochen. Deine Änderungen bleiben im Editor. Bitte erneut versuchen oder als JSON sichern.');
|
||||
}
|
||||
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Sichere deine Änderungen und melde dich erneut an.');
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) conflict = true;
|
||||
let message = response.status === 403 ? 'Für diese Aktion fehlt die Berechtigung oder die Sitzung ist abgelaufen.' : `Die Anfrage ist fehlgeschlagen (${response.status}). Deine Änderungen bleiben im Editor.`;
|
||||
if (response.headers.get('content-type')?.includes('application/json')) message = (await response.json()).error || message;
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.headers.get('content-type')?.includes('application/json') ? response.json() : response.text();
|
||||
}
|
||||
|
||||
function controls() {
|
||||
const ready = Boolean(history);
|
||||
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
|
||||
$('#save').disabled = !ready || busy || conflict || !history.dirty;
|
||||
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
|
||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||
$('#redo').disabled = !ready || busy || !history.future.length;
|
||||
const current = ready ? locate(history.document.blocks, selected) : null;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent']) $(`#${id}`).disabled = !current || busy;
|
||||
if (current) {
|
||||
$('#move-up').disabled ||= current.index === 0;
|
||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
}
|
||||
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#select-page').disabled = !ready || busy;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
|
||||
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
|
||||
const matches = block => `${labels[block.type]} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const build = blocks => {
|
||||
const list = document.createElement('ul'); list.className = 'structure-list';
|
||||
for (const block of blocks) {
|
||||
if (query && !matches(block)) continue;
|
||||
const item = document.createElement('li');
|
||||
if (block.children?.length) {
|
||||
const toggle = document.createElement('button'); toggle.className = 'tree-toggle'; toggle.type = 'button';
|
||||
toggle.textContent = collapsed.has(block.id) && !query ? '▸' : '▾';
|
||||
toggle.dataset.toggle = block.id;
|
||||
toggle.setAttribute('aria-label', `${labels[block.type]} ${block.id} ${collapsed.has(block.id) ? 'öffnen' : 'einklappen'}`);
|
||||
toggle.setAttribute('aria-expanded', String(!collapsed.has(block.id) || Boolean(query)));
|
||||
toggle.disabled = Boolean(query); item.append(toggle);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
|
||||
button.dataset.blockId = block.id;
|
||||
button.setAttribute('aria-pressed', String(block.id === selected));
|
||||
button.setAttribute('aria-label', `${labels[block.type]}: ${block.props?.text || block.props?.alt || labels[block.type]}`);
|
||||
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = icons[block.type];
|
||||
const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || labels[block.type];
|
||||
button.append(icon, text); item.append(button);
|
||||
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||||
list.append(item);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
$('#structure').replaceChildren(build(history.document.blocks));
|
||||
if (!$('#structure .tree-item')) { const empty = document.createElement('p'); empty.className = 'panel-hint'; empty.textContent = 'Keine passenden Elemente.'; $('#structure').append(empty); }
|
||||
if (activeId) Array.from($('#structure').querySelectorAll('button')).find(button => button.dataset.blockId === activeId)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function choose(id, scroll = true) {
|
||||
if (busy) return;
|
||||
selected = locate(history.document.blocks, id) ? id : null;
|
||||
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
||||
if (!selected) propertyPanel = 'content';
|
||||
history.mergeKey = null;
|
||||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||||
}
|
||||
|
||||
function highlight(scroll = false) {
|
||||
const doc = $('#preview').contentDocument;
|
||||
if (!doc) return;
|
||||
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||||
const element = selected ? doc.getElementById(selected) : null;
|
||||
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
||||
}
|
||||
|
||||
function renderProperties() {
|
||||
const block = locate(history.document.blocks, selected)?.block;
|
||||
if (!block) propertyPanel = 'content';
|
||||
$('#properties-title').textContent = block ? labels[block.type] : 'Seiteneinstellungen';
|
||||
const panel = $('#properties'); panel.replaceChildren();
|
||||
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||||
$('#breadcrumbs').replaceChildren();
|
||||
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
|
||||
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? labels[item.type] : 'Seite';
|
||||
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
|
||||
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||||
}
|
||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||
const field = (label, key, value, options = {}) => {
|
||||
const id = `property-${key}`;
|
||||
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||||
let input;
|
||||
if (options.choices) {
|
||||
input = document.createElement('select');
|
||||
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
|
||||
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = value ?? '';
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
input.addEventListener('input', () => {
|
||||
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
|
||||
history.change(doc => {
|
||||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||
if (!current) return;
|
||||
if (options.layout) (current.layout ??= {})[key] = next;
|
||||
else if (selected) { (current.props ??= {})[key] = next; if (key === 'decorative' && next) current.props.alt = ''; }
|
||||
else current[key] = next;
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, `${selected || 'page'}:${key}`);
|
||||
if (key === 'decorative') renderProperties();
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); }
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||
return;
|
||||
}
|
||||
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'image') {
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
|
||||
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||||
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||||
field('Originalbreite (px)', 'width', block.props.width, { number: true });
|
||||
field('Originalhöhe (px)', 'height', block.props.height, { number: true });
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal', narrow: 'Schmal' } });
|
||||
}
|
||||
}
|
||||
|
||||
function renderDesign(block, panel) {
|
||||
const hint = document.createElement('p'); hint.className = 'design-scope';
|
||||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
|
||||
panel.append(hint, help);
|
||||
for (const [key, token] of Object.entries(designTokens)) {
|
||||
if (!token.types.includes(block.type)) continue;
|
||||
const label = document.createElement('label'); label.textContent = token.label; label.htmlFor = `design-${key}`;
|
||||
const select = document.createElement('select'); select.id = label.htmlFor;
|
||||
const previous = device === 'mobile' ? effectiveDesign(block, 'tablet', key) : device === 'tablet' ? effectiveDesign(block, 'desktop', key) : {};
|
||||
const inherited = previous.value ? `${token.values[previous.value][0]} von ${devices[previous.source]}` : 'Theme-Standard';
|
||||
for (const [value, text] of [['', `Übernehmen: ${inherited}`], ...Object.entries(token.values).map(([value, [label]]) => [value, label])]) {
|
||||
const option = document.createElement('option'); option.value = value; option.textContent = text; select.append(option);
|
||||
}
|
||||
select.value = block.layout?.design?.[device]?.[key] || '';
|
||||
select.addEventListener('change', () => {
|
||||
history.change(doc => setDesign(doc, selected, device, key, select.value));
|
||||
controls(); schedulePreview();
|
||||
});
|
||||
panel.append(label, select);
|
||||
}
|
||||
const reset = document.createElement('button'); reset.className = 'secondary design-reset'; reset.textContent = `${devices[device]}-Werte zurücksetzen`;
|
||||
reset.addEventListener('click', () => {
|
||||
history.change(doc => { const current = locate(doc.blocks, selected).block; for (const key of Object.keys(current.layout?.design?.[device] || {})) setDesign(doc, selected, device, key, ''); });
|
||||
changed();
|
||||
}); panel.append(reset);
|
||||
const heading = document.createElement('h3'); heading.className = 'preset-heading'; heading.textContent = 'Effekte · alle Geräte'; panel.append(heading);
|
||||
for (const [key, label, choices] of [
|
||||
['shadow', 'Schatten', { none: 'Keiner', soft: 'Weich', strong: 'Kräftig' }],
|
||||
['hover', 'Hover-Effekt', { none: 'Keiner', lift: 'Leicht anheben', zoom: 'Bild vergrößern' }],
|
||||
['entrance', 'Einblendung beim Laden', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten einblenden' }],
|
||||
]) {
|
||||
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.htmlFor = `effect-${key}`;
|
||||
const select = document.createElement('select'); select.id = labelEl.htmlFor;
|
||||
for (const [value, title] of Object.entries(choices)) { const option = document.createElement('option'); option.value = value; option.textContent = title; select.append(option); }
|
||||
select.value = block.layout?.effects?.[key] || 'none';
|
||||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||||
panel.append(labelEl, select);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder.'; panel.append(note);
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
const target = selected;
|
||||
openMedia({ multiple, onChoose: items => {
|
||||
if (!items.length || busy) return;
|
||||
selected = history.change(doc => insertMedia(doc, target, items)); changed();
|
||||
} });
|
||||
}
|
||||
|
||||
function resizePreview() {
|
||||
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||||
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
|
||||
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||
const available = Math.max(100, container.clientWidth - padding);
|
||||
const scale = Math.min(1, available / width);
|
||||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
|
||||
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
|
||||
frame.title = `Vorschau ${devices[device]} · ${width} px`;
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
clearTimeout(previewTimer);
|
||||
// Invalidate an in-flight response immediately, including during the debounce window.
|
||||
previewSequence++;
|
||||
previewController?.abort();
|
||||
$('#preview-status').textContent = 'Vorschau wird aktualisiert …';
|
||||
previewTimer = setTimeout(updatePreview, 250);
|
||||
}
|
||||
|
||||
async function updatePreview() {
|
||||
const sequence = previewSequence;
|
||||
const controller = new AbortController(); previewController = controller;
|
||||
try {
|
||||
const html = await request('/preview', { document: history.document }, controller.signal);
|
||||
if (sequence !== previewSequence) return;
|
||||
const frame = $('#preview');
|
||||
const top = frame.contentWindow?.scrollY || 0;
|
||||
frame.onload = () => {
|
||||
if (sequence !== previewSequence) return;
|
||||
const doc = frame.contentDocument;
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
|
||||
doc.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
const element = event.target.closest('[id]');
|
||||
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||||
});
|
||||
doc.addEventListener('submit', event => event.preventDefault());
|
||||
frame.contentWindow.scrollTo(0, top); highlight();
|
||||
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||
};
|
||||
frame.srcdoc = html;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError' || sequence !== previewSequence) return;
|
||||
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
if (!locate(history.document.blocks, selected)) selected = null;
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
busy = true; controls();
|
||||
try {
|
||||
const data = await request('');
|
||||
if (!designTokens) {
|
||||
const response = await fetch('/assets/design-tokens.json');
|
||||
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
|
||||
designTokens = await response.json();
|
||||
}
|
||||
history = new EditorHistory(data.document);
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
|
||||
async function save(publish = false) {
|
||||
if (!history || busy || conflict || (publish && history.dirty)) return;
|
||||
const snapshot = clone(history.document);
|
||||
busy = true; controls(); notify();
|
||||
try {
|
||||
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
history.markSaved(snapshot);
|
||||
notify();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
|
||||
$('#library').addEventListener('click', event => {
|
||||
const type = event.target.closest('[data-add]')?.dataset.add;
|
||||
if (!type || busy || !history) return;
|
||||
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
|
||||
});
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
if (!name || busy || !history) return;
|
||||
selected = history.change(doc => insertPreset(doc, name, selected)); propertyPanel = 'content'; changed();
|
||||
});
|
||||
$('#structure').addEventListener('click', event => {
|
||||
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
|
||||
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
|
||||
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
|
||||
});
|
||||
$('#tree-search').addEventListener('input', renderTree);
|
||||
$('#toggle-library').addEventListener('click', () => {
|
||||
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
|
||||
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
|
||||
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
|
||||
$('#toggle-library').textContent = hidden ? '+' : '−';
|
||||
});
|
||||
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
|
||||
$('#collapse-all').addEventListener('click', () => {
|
||||
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
|
||||
visit(history.document.blocks); renderTree();
|
||||
});
|
||||
document.querySelectorAll('[data-panel]').forEach(button => button.addEventListener('click', () => { propertyPanel = button.dataset.panel; history.mergeKey = null; renderProperties(); controls(); }));
|
||||
$('#structure').addEventListener('dragstart', event => {
|
||||
if (busy) { event.preventDefault(); return; }
|
||||
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
|
||||
});
|
||||
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
|
||||
$('#structure').addEventListener('drop', event => {
|
||||
event.preventDefault(); if (busy) return;
|
||||
const target = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||
const source = event.dataTransfer.getData('text/plain');
|
||||
if (!target || !source) return;
|
||||
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
|
||||
selected = source; changed();
|
||||
});
|
||||
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
|
||||
$('#delete').addEventListener('click', () => {
|
||||
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
|
||||
});
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
|
||||
});
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
});
|
||||
$('#select-page').addEventListener('click', () => choose(null));
|
||||
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||||
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||||
$('#save').addEventListener('click', () => save());
|
||||
$('#publish')?.addEventListener('click', () => $('#publish-dialog').showModal());
|
||||
$('#cancel-publish').addEventListener('click', () => $('#publish-dialog').close());
|
||||
$('#confirm-publish').addEventListener('click', () => { $('#publish-dialog').close(); save(true); });
|
||||
$('#reload').addEventListener('click', () => $('#reload-dialog').showModal());
|
||||
$('#cancel-reload').addEventListener('click', () => $('#reload-dialog').close());
|
||||
$('#confirm-reload').addEventListener('click', () => { $('#reload-dialog').close(); load(); });
|
||||
$('#download').addEventListener('click', () => {
|
||||
if (!history) return;
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
$('#preview').className = `viewport-${button.dataset.viewport}`;
|
||||
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
|
||||
}));
|
||||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 's') { event.preventDefault(); save(); }
|
||||
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
|
||||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||
});
|
||||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||
load();
|
||||
@@ -0,0 +1,5 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 1000 600"><rect width="1000" height="600" fill="#e9efdf"/><circle cx="735" cy="160" r="70" fill="#b7ca91"/><path d="M0 520 310 200 610 520Z" fill="#7e9b77"/><path d="M370 600 720 280 1000 540V600Z" fill="#385c49"/><path d="M0 515Q450 400 1000 565V600H0Z" fill="#203b34"/></svg>
|
||||
|
After Width: | Height: | Size: 357 B |
@@ -0,0 +1,101 @@
|
||||
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'media-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">BILDER FÜR DEINE WEBSITE</p><h2 id="media-title">Medienverwaltung</h2></div><button class="secondary" id="media-close">Schließen</button></div>
|
||||
<p class="panel-hint">JPEG, PNG oder WebP · bis 8 MB und 12 Megapixel. Bilder werden optimiert und sind über ihre Bildadresse öffentlich erreichbar. Archivieren erhält bestehende Verwendungen.</p>
|
||||
<div class="media-tools"><div><label for="media-search">Bilder suchen</label><input id="media-search" type="search" placeholder="Titel oder Alternativtext"></div><div><label for="media-upload">Bilder hochladen</label><input id="media-upload" type="file" accept="image/jpeg,image/png,image/webp" multiple></div></div>
|
||||
<label class="check-label"><input id="media-archived" type="checkbox"> Archivierte Bilder anzeigen</label>
|
||||
<p id="media-status" role="status" aria-live="polite">Wird geladen …</p><p id="media-error" role="alert" hidden></p>
|
||||
<div class="media-layout"><div id="media-grid" class="media-grid" role="group" aria-label="Bilder auswählen"></div><div id="media-details" class="media-details"><p>Ein Bild auswählen, um seine Angaben zu bearbeiten.</p></div></div>
|
||||
<div class="media-footer"><label class="check-label" id="decorative-choice"><input id="media-decorative" type="checkbox"> Auswahl in dieser Seite dekorativ verwenden</label><button id="media-insert" class="primary" disabled>Auswahl übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], selected = new Set(), active = null, token, working = false;
|
||||
const error = message => { $('#media-error').textContent = message; $('#media-error').hidden = !message; };
|
||||
const lock = state => {
|
||||
working = state; dialog.querySelectorAll('button,input').forEach(node => node.disabled = state);
|
||||
$('#media-insert').disabled = state || !selected.size;
|
||||
};
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
|
||||
return data;
|
||||
}
|
||||
function render() {
|
||||
const focusedMedia = dialog.contains(document.activeElement) ? document.activeElement.dataset.mediaId : null;
|
||||
const query = $('#media-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => ($('#media-archived').checked || !item.archived) && `${item.title} ${item.alt}`.toLocaleLowerCase('de').includes(query));
|
||||
$('#media-grid').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'media-card'); card.type = 'button'; card.dataset.mediaId = item.id;
|
||||
card.setAttribute('aria-label', `${item.title}${item.archived ? ' · Archiviert' : ''}`);
|
||||
card.setAttribute('aria-pressed', String(selected.has(item.id))); card.disabled = working;
|
||||
const image = element('img'); image.src = item.variants[0].src; image.alt = ''; image.loading = 'lazy'; image.width = 180; image.height = 130;
|
||||
card.append(image, element('strong', item.title), element('span', item.archived ? 'Archiviert' : `${item.variants.at(-1).width} × ${item.variants.at(-1).height} px`));
|
||||
card.addEventListener('click', () => {
|
||||
active = item;
|
||||
if (!multiple) selected.clear();
|
||||
if (selected.has(item.id)) selected.delete(item.id); else if (!item.archived) selected.add(item.id);
|
||||
render(); details();
|
||||
}); $('#media-grid').append(card);
|
||||
}
|
||||
if (!visible.length) $('#media-grid').append(element('p', 'Keine Bilder gefunden. Lade ein Bild hoch oder ändere den Suchbegriff.', 'muted'));
|
||||
$('#media-status').textContent = `${items.filter(item => !item.archived).length} Bilder · ${selected.size} ausgewählt`;
|
||||
$('#media-insert').textContent = `Auswahl übernehmen (${selected.size})`; $('#media-insert').disabled = working || !selected.size;
|
||||
if (focusedMedia) dialog.querySelector(`[data-media-id="${focusedMedia}"]`)?.focus({ preventScroll: true });
|
||||
}
|
||||
function details() {
|
||||
const panel = $('#media-details'); panel.replaceChildren(); if (!active) return;
|
||||
const current = active;
|
||||
panel.append(element('h3', 'Bildangaben'));
|
||||
for (const [key, label] of [['title', 'Bildtitel'], ['alt', 'Alternativtext des Mediums']]) {
|
||||
const caption = element('label', label); caption.htmlFor = key === 'title' ? 'media-title-field' : `media-${key}`;
|
||||
const input = element('input'); input.id = caption.htmlFor; input.value = current[key]; input.maxLength = key === 'title' ? 160 : 300;
|
||||
panel.append(caption, input);
|
||||
}
|
||||
panel.append(element('p', 'Alt-Text beschreibt den Bildinhalt. Änderungen hier gelten für zukünftiges Einfügen; vorhandene Seiten behalten ihre eigenen Texte.', 'field-help'));
|
||||
const save = element('button', 'Bildangaben speichern', 'secondary'); save.id = 'media-save';
|
||||
const archive = element('button', current.archived ? 'Wiederherstellen' : 'Archivieren', 'secondary'); archive.id = 'media-archive';
|
||||
const update = async archived => {
|
||||
const data = { title: $('#media-title-field').value, alt: $('#media-alt').value, archived, version: current.version };
|
||||
lock(true); error('');
|
||||
try { const result = await api('/' + current.id, data); Object.assign(current, result.item); if (archived) selected.delete(current.id); render(); details(); }
|
||||
catch (err) { error(err.message || 'Verbindung unterbrochen. Bitte erneut versuchen.'); }
|
||||
finally { lock(false); $('#media-save')?.focus({ preventScroll: true }); }
|
||||
};
|
||||
save.addEventListener('click', () => update(current.archived)); archive.addEventListener('click', () => update(!current.archived));
|
||||
panel.append(save, archive);
|
||||
const link = element('a', 'Bilddatei öffnen ↗'); link.href = current.variants.at(-1).src; link.target = '_blank'; link.rel = 'noopener'; panel.append(link);
|
||||
}
|
||||
$('#media-search').addEventListener('input', render); $('#media-archived').addEventListener('change', render);
|
||||
$('#media-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('cancel', event => { if (working) event.preventDefault(); });
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); else document.querySelector('#properties button')?.focus(); });
|
||||
$('#media-upload').addEventListener('change', async () => {
|
||||
const files = [...$('#media-upload').files]; if (!files.length) return;
|
||||
lock(true); error(''); const failures = [];
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index]; $('#media-status').textContent = `Upload ${index + 1} von ${files.length}: ${file.name}`;
|
||||
if (file.size > 8 * 1024 * 1024) { failures.push(`${file.name}: größer als 8 MB`); continue; }
|
||||
try { const form = new FormData(); form.append('file', file); const data = await api('/upload', form); items.unshift(data.item); active = data.item; }
|
||||
catch (err) { failures.push(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
}
|
||||
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
|
||||
});
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
|
||||
$('#media-insert').addEventListener('click', () => {
|
||||
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
|
||||
const decorative = $('#media-decorative').checked;
|
||||
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
|
||||
});
|
||||
dialog.showModal(); lock(true);
|
||||
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -0,0 +1,2 @@
|
||||
[id].editor-selected{outline:3px solid #437754!important;outline-offset:-3px;scroll-margin-top:14px}
|
||||
[id].editor-hover:not(.editor-selected){outline:2px dashed #64864e;outline-offset:-2px}
|
||||
@@ -0,0 +1,6 @@
|
||||
@charset "UTF-8";
|
||||
h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{display:flex;justify-content:space-between;align-items:center;min-height:106px;border-bottom:1px solid var(--line);gap:24px}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;padding-block:34px;border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header{min-height:100px;align-items:flex-start;flex-direction:column;justify-content:center;gap:18px;padding-block:22px}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
Reference in new issue
Block a user