Travel — Guide Editor
A CMS-lite travel guide editor with three working panes. A left outline lists every section with a draggable handle to reorder, plus add and delete controls. The center canvas holds an editable cover (title, mood scene, best-time badge) and a stream of insertable blocks — POI cards with save-to-trip, a CSS map block with routed pins, and tip callouts. The right inspector edits section settings, a publish toggle, and a live SEO preview, while a reader preview modal renders only the published sections.
MCP
Code
:root {
--bg: #fbf7f1;
--surface: #ffffff;
--surface-2: #f6efe4;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-d: #176b6b;
--coral: #e8623f;
--coral-d: #cf4a29;
--sand: #e7d8c3;
--sand-soft: #f3ebde;
--gold: #d9a441;
--line: rgba(36, 31, 26, 0.12);
--line-2: rgba(36, 31, 26, 0.22);
--ok: #2f8f5b;
--shadow-sm: 0 1px 2px rgba(36, 31, 26, 0.08);
--shadow-md: 0 12px 30px rgba(36, 31, 26, 0.14);
--shadow-lg: 0 24px 60px rgba(36, 31, 26, 0.22);
--r-sm: 9px;
--r-md: 14px;
--r-lg: 20px;
--topbar-h: 60px;
--sans: "Work Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--serif: "Fraunces", Georgia, "Times New Roman", serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
h1, h2, h3 { margin: 0; line-height: 1.18; }
button { font-family: inherit; cursor: pointer; }
::selection { background: rgba(31, 138, 138, 0.22); }
.skip-link {
position: absolute;
left: 12px;
top: -48px;
z-index: 60;
background: var(--ink);
color: #fff;
padding: 8px 14px;
border-radius: var(--r-sm);
font-weight: 600;
text-decoration: none;
transition: top 0.15s;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 2px solid var(--teal);
outline-offset: 2px;
border-radius: 4px;
}
/* ====================== TOP BAR ====================== */
.topbar {
height: var(--topbar-h);
display: flex;
align-items: center;
gap: 18px;
padding: 0 18px;
background: var(--surface);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 30;
}
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
.brand__mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 11px;
color: #fff;
background: linear-gradient(145deg, var(--teal), var(--teal-d));
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.brand__text { display: flex; flex-direction: column; line-height: 1.1; }
.brand__text strong {
font-family: var(--serif);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.01em;
}
.brand__text span {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
}
.doc-meta {
margin: 0 auto;
text-align: center;
display: flex;
flex-direction: column;
min-width: 0;
}
.doc-meta__title {
font-family: var(--serif);
font-weight: 600;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-meta__status {
font-size: 11.5px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
}
.doc-meta__status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
}
.doc-meta__status[data-dirty="1"] { color: var(--coral-d); }
.doc-meta__status[data-dirty="1"]::before { background: var(--gold); }
.topbar__actions { display: flex; gap: 9px; flex-shrink: 0; }
.btn {
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink);
border-radius: var(--r-sm);
padding: 9px 15px;
font-weight: 600;
font-size: 13.5px;
display: inline-flex;
align-items: center;
gap: 7px;
transition: transform 0.12s, box-shadow 0.15s, background 0.15s, border-color 0.15s;
}
.btn:hover { background: var(--sand-soft); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn--ghost { background: transparent; }
.btn--primary {
background: var(--coral);
border-color: var(--coral);
color: #fff;
box-shadow: var(--shadow-sm);
}
.btn--primary:hover { background: var(--coral-d); border-color: var(--coral-d); }
.btn--primary.is-live {
background: var(--teal);
border-color: var(--teal);
}
.btn--primary.is-live:hover { background: var(--teal-d); border-color: var(--teal-d); }
.btn--mini {
padding: 6px 11px;
font-size: 12.5px;
border-radius: 8px;
}
/* ====================== WORKSPACE LAYOUT ====================== */
.workspace {
display: grid;
grid-template-columns: 272px minmax(0, 1fr) 320px;
height: calc(100vh - var(--topbar-h));
overflow: hidden;
}
.panel { background: var(--surface); min-height: 0; }
.outline { border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.inspector { border-left: 1px solid var(--line); display: flex; flex-direction: column; }
.panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 15px 16px 11px;
flex-shrink: 0;
}
.panel__title {
font-family: var(--serif);
font-size: 16px;
font-weight: 600;
}
/* ---------- OUTLINE ---------- */
.outline__hint {
margin: 0 16px 10px;
font-size: 11.5px;
color: var(--muted);
}
.section-list {
list-style: none;
margin: 0;
padding: 0 10px 8px;
overflow-y: auto;
flex: 1;
}
.sec {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 10px;
border: 1px solid transparent;
border-radius: var(--r-md);
margin-bottom: 5px;
background: var(--surface);
transition: background 0.14s, border-color 0.14s, box-shadow 0.14s;
position: relative;
}
.sec:hover { background: var(--sand-soft); }
.sec.is-active {
background: var(--sand-soft);
border-color: var(--sand);
box-shadow: inset 3px 0 0 var(--teal);
}
.sec.is-dragging { opacity: 0.45; }
.sec.is-over { box-shadow: 0 -2px 0 var(--coral); }
.sec__handle {
cursor: grab;
color: var(--muted);
font-size: 15px;
line-height: 1.3;
padding: 1px 2px;
border-radius: 5px;
user-select: none;
flex-shrink: 0;
background: none;
border: 0;
}
.sec__handle:hover { color: var(--ink); background: rgba(36, 31, 26, 0.06); }
.sec__handle:active { cursor: grabbing; }
.sec__body { flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 0; color: inherit; }
.sec__name {
display: block;
font-weight: 600;
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sec__meta {
display: flex;
align-items: center;
gap: 7px;
margin-top: 3px;
font-size: 11px;
color: var(--muted);
}
.sec__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ok);
flex-shrink: 0;
}
.sec[data-published="false"] .sec__dot { background: var(--line-2); }
.sec[data-published="false"] .sec__name { color: var(--muted); }
.sec__del {
border: 0;
background: none;
color: var(--muted);
font-size: 14px;
line-height: 1;
padding: 3px 5px;
border-radius: 6px;
opacity: 0;
transition: opacity 0.12s, background 0.12s, color 0.12s;
flex-shrink: 0;
}
.sec:hover .sec__del, .sec:focus-within .sec__del { opacity: 1; }
.sec__del:hover { background: rgba(232, 98, 63, 0.14); color: var(--coral-d); }
.outline__foot {
display: flex;
justify-content: space-between;
padding: 11px 18px;
border-top: 1px solid var(--line);
font-size: 11.5px;
color: var(--muted);
font-weight: 600;
flex-shrink: 0;
}
/* ====================== CANVAS ====================== */
.canvas-wrap {
overflow-y: auto;
padding: 26px 24px 60px;
background:
radial-gradient(120% 80% at 50% -10%, rgba(31, 138, 138, 0.05), transparent 60%),
var(--bg);
}
.canvas-wrap:focus { outline: none; }
.canvas {
max-width: 720px;
margin: 0 auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.cover {
position: relative;
min-height: 230px;
display: flex;
align-items: flex-end;
padding: 26px;
color: #fff;
overflow: hidden;
}
.cover__scene {
position: absolute;
inset: 0;
z-index: 0;
}
.cover__scene::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(20, 14, 10, 0.62) 0%, rgba(20, 14, 10, 0.12) 55%, transparent 100%);
}
/* scene variants drawn purely in CSS */
.cover[data-scene="city"] .cover__scene {
background:
linear-gradient(180deg, #ffd9a8 0%, #f7b27a 48%, #e8916a 100%);
}
.cover[data-scene="city"] .cover__scene::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(180deg, transparent 52%, #c9603f 52%) 0 0 / 60px 100% repeat-x,
linear-gradient(180deg, transparent 62%, #a94c30 62%) 30px 0 / 90px 100% repeat-x;
-webkit-mask: linear-gradient(180deg, transparent 40%, #000 40%);
mask: linear-gradient(180deg, transparent 40%, #000 40%);
opacity: 0.9;
}
.cover[data-scene="coast"] .cover__scene {
background: linear-gradient(180deg, #bfe6f0 0%, #8ec9d6 42%, #3a9ca8 100%);
}
.cover[data-scene="coast"] .cover__scene::before {
content: "";
position: absolute;
left: -10%; right: -10%; bottom: -10%;
height: 55%;
background: radial-gradient(120% 130% at 30% 0%, #f3e3c2, #e9cf9f 70%);
border-radius: 50% 50% 0 0 / 70% 70% 0 0;
box-shadow: inset 0 6px 0 rgba(255, 255, 255, 0.25);
}
.cover[data-scene="alpine"] .cover__scene {
background: linear-gradient(180deg, #cfe3ec 0%, #9ec1d2 50%, #6f95a8 100%);
}
.cover[data-scene="alpine"] .cover__scene::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(135deg, transparent 49.5%, #5d7589 50%) 0 0 / 50% 100%,
linear-gradient(225deg, transparent 49.5%, #6f8799 50%) 50% 0 / 50% 100%;
-webkit-mask: linear-gradient(180deg, transparent 28%, #000 28%);
mask: linear-gradient(180deg, transparent 28%, #000 28%);
}
.cover[data-scene="desert"] .cover__scene {
background: linear-gradient(180deg, #f6cf8e 0%, #e9a463 46%, #cf7b46 100%);
}
.cover[data-scene="desert"] .cover__scene::before {
content: "";
position: absolute;
left: -10%; right: -10%; bottom: 0;
height: 60%;
background:
radial-gradient(80% 120% at 25% 100%, #d98c4f, transparent 70%),
radial-gradient(90% 120% at 80% 100%, #c97a3e, transparent 70%);
border-radius: 50%;
}
.cover__overlay { position: relative; z-index: 1; max-width: 100%; }
.cover__eyebrow {
display: inline-block;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(2px);
padding: 4px 10px;
border-radius: 100px;
margin-bottom: 9px;
}
.cover__title {
font-family: var(--serif);
font-size: clamp(26px, 4.6vw, 38px);
font-weight: 600;
letter-spacing: -0.01em;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.3);
outline: none;
border-radius: 6px;
}
.cover__title:focus-visible { outline: 2px dashed rgba(255, 255, 255, 0.8); outline-offset: 4px; }
.cover__badge {
position: absolute;
z-index: 1;
top: 18px;
right: 18px;
font-size: 11px;
font-weight: 700;
background: var(--gold);
color: #2a1f10;
padding: 5px 11px;
border-radius: 100px;
box-shadow: var(--shadow-sm);
}
.cover__draft {
position: absolute;
z-index: 1;
top: 18px;
left: 18px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(36, 31, 26, 0.72);
color: #fff;
padding: 5px 10px;
border-radius: 100px;
}
.cover[data-published="true"] .cover__draft { display: none; }
/* ---------- BLOCKS ---------- */
.blocks { padding: 24px 30px 32px; }
.block-intro {
font-size: 16.5px;
color: #3a342d;
line-height: 1.62;
margin: 4px 0 6px;
}
.block-intro::first-letter {
font-family: var(--serif);
font-size: 2.6em;
font-weight: 600;
float: left;
line-height: 0.82;
padding: 6px 9px 0 0;
color: var(--coral);
}
.block {
position: relative;
margin: 18px 0;
}
.block__bar {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.13s;
}
.block:hover .block__bar, .block:focus-within .block__bar { opacity: 1; }
.block__act {
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
width: 26px;
height: 26px;
border-radius: 7px;
font-size: 13px;
line-height: 1;
display: grid;
place-items: center;
box-shadow: var(--shadow-sm);
}
.block__act:hover { color: var(--coral-d); border-color: var(--sand); }
/* POI card */
.poi {
display: grid;
grid-template-columns: 86px 1fr auto;
gap: 14px;
align-items: center;
padding: 13px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.poi__thumb {
width: 86px;
height: 70px;
border-radius: 10px;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
font-size: 26px;
}
.poi__thumb[data-tone="0"] { background: linear-gradient(135deg, #ffd9a8, #e8916a); }
.poi__thumb[data-tone="1"] { background: linear-gradient(135deg, #bfe6f0, #3a9ca8); }
.poi__thumb[data-tone="2"] { background: linear-gradient(135deg, #cfe3ec, #6f95a8); }
.poi__thumb[data-tone="3"] { background: linear-gradient(135deg, #f6cf8e, #cf7b46); }
.poi__main { min-width: 0; }
.poi__name {
font-family: var(--serif);
font-weight: 600;
font-size: 16px;
margin: 0 0 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poi__cat { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; }
.poi__desc { font-size: 13px; color: #4a443c; margin: 5px 0 0; }
.poi__side { text-align: right; flex-shrink: 0; }
.poi__rating {
font-weight: 700;
font-size: 13.5px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.poi__rating::before { content: "★"; color: var(--gold); }
.poi__price { display: block; margin-top: 3px; font-size: 12px; color: var(--teal-d); font-weight: 700; letter-spacing: 0.05em; }
.poi__save {
margin-top: 7px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: 100px;
padding: 4px 10px;
font-size: 11.5px;
font-weight: 600;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.14s;
}
.poi__save:hover { border-color: var(--coral); color: var(--coral-d); }
.poi__save.is-saved { background: var(--coral); border-color: var(--coral); color: #fff; }
/* Tip callout */
.tip {
display: flex;
gap: 12px;
padding: 14px 16px;
background: linear-gradient(135deg, var(--sand-soft), #fff);
border: 1px solid var(--sand);
border-left: 4px solid var(--teal);
border-radius: var(--r-md);
}
.tip__icon { font-size: 20px; flex-shrink: 0; }
.tip__label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--teal-d); display: block; margin-bottom: 2px; }
.tip__text {
margin: 0;
font-size: 13.5px;
color: #3a342d;
outline: none;
border-radius: 5px;
}
.tip__text:focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; }
/* Map block */
.mapb {
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.mapb__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 13px;
border-bottom: 1px solid var(--line);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.mapb__canvas { position: relative; height: 190px; }
.mapb__svg { width: 100%; height: 100%; display: block; }
.mapb__pins { position: absolute; inset: 0; pointer-events: none; }
.pin {
position: absolute;
transform: translate(-50%, -100%);
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
font-weight: 700;
color: var(--ink);
}
.pin__dot {
width: 22px;
height: 22px;
border-radius: 50% 50% 50% 0;
background: var(--coral);
transform: rotate(-45deg);
display: grid;
place-items: center;
box-shadow: var(--shadow-sm);
border: 2px solid #fff;
}
.pin__dot span { transform: rotate(45deg); color: #fff; font-size: 10px; }
.pin__label {
margin-top: 3px;
background: rgba(255, 255, 255, 0.92);
padding: 1px 6px;
border-radius: 5px;
white-space: nowrap;
box-shadow: var(--shadow-sm);
}
.mapb__note { margin: 0; padding: 9px 13px; font-size: 11.5px; color: var(--muted); border-top: 1px solid var(--line); }
.blocks__empty {
text-align: center;
padding: 30px 16px;
border: 1px dashed var(--line-2);
border-radius: var(--r-md);
color: var(--muted);
font-size: 13.5px;
}
/* ====================== INSPECTOR ====================== */
.inspector__tag {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--teal-d);
background: rgba(31, 138, 138, 0.12);
padding: 3px 9px;
border-radius: 100px;
}
.inspector__body {
overflow-y: auto;
padding: 4px 16px 24px;
flex: 1;
}
.field-group {
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.field-group:last-of-type { border-bottom: 0; }
.field-group__title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: 10px;
}
.field { display: block; margin-bottom: 12px; }
.field:last-child { margin-bottom: 0; }
.field__label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--muted);
margin-bottom: 5px;
}
.field__input {
width: 100%;
border: 1px solid var(--line-2);
background: var(--surface);
border-radius: 9px;
padding: 9px 11px;
font-size: 13.5px;
color: var(--ink);
font-family: inherit;
transition: border-color 0.14s, box-shadow 0.14s;
}
.field__input:focus {
outline: none;
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(31, 138, 138, 0.16);
}
.field__textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
.slug-wrap { display: flex; align-items: stretch; }
.slug-pre {
display: flex;
align-items: center;
background: var(--surface-2);
border: 1px solid var(--line-2);
border-right: 0;
border-radius: 9px 0 0 9px;
padding: 0 9px;
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
}
.slug-in { border-radius: 0 9px 9px 0; }
.scene-pick { display: flex; gap: 8px; }
.scene-sw {
width: 46px;
height: 34px;
border-radius: 8px;
border: 2px solid transparent;
box-shadow: var(--shadow-sm), inset 0 0 0 1px rgba(0,0,0,0.06);
position: relative;
transition: transform 0.12s;
}
.scene-sw:hover { transform: translateY(-2px); }
.scene-sw[aria-checked="true"] { border-color: var(--ink); }
.scene-sw[aria-checked="true"]::after {
content: "✓";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-size: 13px;
text-shadow: 0 1px 2px rgba(0,0,0,0.4);
}
.scene-sw--city { background: linear-gradient(135deg, #ffd9a8, #e8916a); }
.scene-sw--coast { background: linear-gradient(135deg, #bfe6f0, #3a9ca8); }
.scene-sw--alpine { background: linear-gradient(135deg, #cfe3ec, #6f95a8); }
.scene-sw--desert { background: linear-gradient(135deg, #f6cf8e, #cf7b46); }
.block-add { display: flex; flex-wrap: wrap; gap: 7px; }
.chip-btn {
border: 1px solid var(--line-2);
background: var(--surface);
border-radius: 100px;
padding: 7px 13px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink);
transition: all 0.13s;
}
.chip-btn:hover {
background: var(--teal);
border-color: var(--teal);
color: #fff;
transform: translateY(-1px);
}
.seg {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
background: var(--surface-2);
padding: 4px;
border-radius: 10px;
border: 1px solid var(--line);
}
.seg__btn {
border: 0;
background: transparent;
border-radius: 7px;
padding: 7px 4px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
transition: all 0.13s;
}
.seg__btn:hover { color: var(--ink); }
.seg__btn.is-on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.toggle {
display: flex;
align-items: center;
gap: 11px;
cursor: pointer;
user-select: none;
}
.toggle input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.toggle__track {
width: 42px;
height: 24px;
border-radius: 100px;
background: var(--line-2);
position: relative;
transition: background 0.16s;
flex-shrink: 0;
}
.toggle__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: var(--shadow-sm);
transition: transform 0.16s;
}
.toggle input:checked + .toggle__track { background: var(--ok); }
.toggle input:checked + .toggle__track .toggle__thumb { transform: translateX(18px); }
.toggle input:focus-visible + .toggle__track { outline: 2px solid var(--teal); outline-offset: 2px; }
.toggle__text { font-size: 13px; font-weight: 600; }
.seo {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px 13px;
display: flex;
flex-direction: column;
gap: 3px;
}
.seo__url { font-size: 11.5px; color: var(--teal-d); }
.seo__title {
font-family: var(--serif);
font-size: 15px;
font-weight: 600;
color: #1a3d8f;
line-height: 1.3;
}
.seo__desc { margin: 2px 0 0; font-size: 12.5px; color: #4a443c; line-height: 1.45; }
.seo__meter {
height: 5px;
border-radius: 100px;
background: var(--line);
margin-top: 7px;
overflow: hidden;
}
.seo__meter-bar {
display: block;
height: 100%;
width: 0;
background: var(--ok);
border-radius: 100px;
transition: width 0.2s, background 0.2s;
}
.seo__hint { font-size: 11px; font-weight: 600; color: var(--muted); margin-top: 4px; }
.insp-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 11px 6px;
text-align: center;
}
.stat strong { display: block; font-family: var(--serif); font-size: 19px; font-weight: 600; }
.stat span { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
/* ====================== TOAST ====================== */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translate(-50%, 20px);
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 100px;
font-size: 13.5px;
font-weight: 600;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 80;
display: inline-flex;
align-items: center;
gap: 8px;
}
.toast.is-show { opacity: 1; transform: translate(-50%, 0); }
/* ====================== MODAL ====================== */
.modal {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(36, 31, 26, 0.55);
display: grid;
place-items: center;
padding: 20px;
backdrop-filter: blur(3px);
}
.modal[hidden] { display: none; }
.modal__panel {
background: var(--bg);
width: min(560px, 100%);
max-height: 88vh;
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
animation: pop 0.18s ease-out;
}
@keyframes pop { from { transform: scale(0.96); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 18px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.modal__head h2 { font-family: var(--serif); font-size: 17px; font-weight: 600; }
.modal__body { overflow-y: auto; padding: 0; }
/* reader preview rendering */
.rd-cover {
min-height: 180px;
display: flex;
align-items: flex-end;
padding: 22px;
color: #fff;
position: relative;
}
.rd-cover__eyebrow { position: relative; z-index: 1; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 700; opacity: 0.9; }
.rd-cover h3 { position: relative; z-index: 1; font-family: var(--serif); font-size: 26px; font-weight: 600; margin-top: 4px; text-shadow: 0 2px 12px rgba(0,0,0,0.3); }
.rd-cover::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, rgba(20,14,10,0.6), transparent 70%); }
.rd-body { padding: 18px 22px 24px; }
.rd-body p { font-size: 14.5px; line-height: 1.6; color: #3a342d; }
.rd-poi { display: flex; gap: 10px; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line); }
.rd-poi strong { font-family: var(--serif); font-weight: 600; }
.rd-poi__r { margin-left: auto; font-weight: 700; color: var(--gold); }
.rd-empty { padding: 30px; text-align: center; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* ====================== RESPONSIVE ====================== */
@media (max-width: 1080px) {
.workspace { grid-template-columns: 240px minmax(0, 1fr) 290px; }
}
@media (max-width: 900px) {
.workspace {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: auto;
overflow: visible;
}
.outline {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: 220px;
}
.inspector {
border-left: 0;
border-top: 1px solid var(--line);
order: 3;
}
.canvas-wrap { order: 2; max-height: none; }
.doc-meta { display: none; }
}
@media (max-width: 560px) {
.topbar { gap: 10px; padding: 0 12px; }
.btn span:not(.brand__text span) { display: none; }
.btn--ghost { padding: 9px 11px; }
.canvas-wrap { padding: 16px 12px 48px; }
.blocks { padding: 18px 16px 24px; }
.poi { grid-template-columns: 56px 1fr; }
.poi__thumb { width: 56px; height: 56px; font-size: 20px; }
.poi__side { grid-column: 1 / -1; text-align: left; display: flex; align-items: center; gap: 12px; }
.poi__save { margin-top: 0; }
.insp-stats { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 380px) {
.seg { grid-template-columns: repeat(2, 1fr); }
}/* ===================================================================
Wander — Guide Editor
Vanilla JS, no libraries. Drives a 3-pane travel-CMS:
left outline (drag-reorder / add / delete), center editable canvas
(cover + POI/map/tip blocks), right inspector (settings/SEO/stats).
=================================================================== */
(function () {
"use strict";
/* ---------- tiny helpers ---------- */
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const uid = () => "s" + Math.random().toString(36).slice(2, 8);
const esc = (s) =>
String(s).replace(/[&<>"']/g, (c) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}[c]));
const slugify = (s) =>
String(s)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "untitled";
const words = (s) => (String(s).trim().match(/\b[\w’'-]+\b/g) || []).length;
let toastTimer;
function toast(msg, icon = "✓") {
const el = $("#toast");
el.innerHTML = '<span aria-hidden="true">' + icon + "</span>" + esc(msg);
el.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("is-show"), 2200);
}
/* ---------- block factories ---------- */
const TONES = 4;
function poiBlock(over) {
const pools = [
{ name: "Time Out Mercado", cat: "Food hall", desc: "Two dozen stalls under one roof — go hungry, go early.", rating: "4.6", price: "€€", tone: 0 },
{ name: "Miradouro da Graça", cat: "Viewpoint", desc: "Best light an hour before sunset, a pine for shade.", rating: "4.8", price: "Free", tone: 1 },
{ name: "Tram 28", cat: "Ride", desc: "The rattling line through Alfama — board at Martim Moniz.", rating: "4.3", price: "€", tone: 2 },
{ name: "Pastéis de Belém", cat: "Bakery", desc: "The original custard tart, warm with cinnamon on top.", rating: "4.7", price: "€", tone: 3 },
{ name: "LX Factory", cat: "Design district", desc: "Old print works turned shops, bookstores and rooftops.", rating: "4.5", price: "€€", tone: 0 },
];
return Object.assign({ id: uid(), type: "poi", saved: false }, pools[(over || 0) % pools.length]);
}
function tipBlock() {
const tips = [
"Buy a 24-hour Viva Viagem card — trams, metro and the funiculars all accept it.",
"Restaurants put a couvert (bread, olives) on the table; wave it away if you won’t eat it.",
"Hills are steep — pack one comfortable pair of shoes and skip the heels.",
];
return { id: uid(), type: "tip", text: tips[Math.floor(Math.random() * tips.length)] };
}
function mapBlock() {
return {
id: uid(),
type: "map",
title: "Walking route",
note: "≈ 2.4 km · a gentle morning loop through the old town.",
pins: [
{ x: 18, y: 64, n: 1, label: "Sé" },
{ x: 46, y: 38, n: 2, label: "Graça" },
{ x: 72, y: 58, n: 3, label: "Castelo" },
{ x: 86, y: 30, n: 4, label: "Mirante" },
],
};
}
/* ---------- initial document state ---------- */
const state = {
docTitle: "Lisboa in 72 Hours",
activeId: null,
sections: [
{
id: uid(),
name: "Arrival & Alfama",
eyebrow: "City Guide · Portugal",
body:
"You land into low afternoon light and the smell of grilled sardines. Drop your bag, then let gravity pull you downhill through Alfama — the oldest, most tangled quarter, where laundry lines cross the alleys and every corner hides a viewpoint.",
scene: "city",
best: "Spring",
published: true,
slug: "arrival",
blocks: [poiBlock(0), tipBlock(), poiBlock(1)],
},
{
id: uid(),
name: "A Day by the River",
eyebrow: "Day Two · Belém",
body:
"Trade the hills for the Tagus. Belém spreads flat and bright along the water — monasteries, a tower, and the custard tart that started it all. Time it so the afternoon ferry carries you back across the estuary as the bridge lights flick on.",
scene: "coast",
best: "Summer",
published: true,
slug: "river",
blocks: [mapBlock(), poiBlock(3)],
},
{
id: uid(),
name: "Day Trip — Sintra",
eyebrow: "Day Three · Hills",
body:
"Catch the early train before the crowds. Sintra is cooler, greener and a little unreal: palaces painted like sweets, a Moorish wall along the ridge, and forest paths that smell of eucalyptus and rain.",
scene: "alpine",
best: "Autumn",
published: false,
slug: "sintra",
blocks: [tipBlock()],
},
],
};
state.activeId = state.sections[0].id;
/* ---------- dirty / save indicator ---------- */
let saveTimer;
function markDirty() {
const s = $("#saveState");
s.textContent = "Saving…";
s.setAttribute("data-dirty", "1");
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
s.textContent = "All changes saved";
s.removeAttribute("data-dirty");
}, 750);
}
const active = () => state.sections.find((s) => s.id === state.activeId);
/* =================================================================
OUTLINE (left)
================================================================= */
const listEl = $("#sectionList");
function renderOutline() {
listEl.innerHTML = "";
state.sections.forEach((sec, i) => {
const li = document.createElement("li");
li.className = "sec" + (sec.id === state.activeId ? " is-active" : "");
li.draggable = false;
li.dataset.id = sec.id;
li.dataset.published = String(sec.published);
li.innerHTML =
'<button class="sec__handle" type="button" aria-label="Drag to reorder" draggable="true" title="Drag to reorder (or Alt+↑/↓)">⠿</button>' +
'<button class="sec__body" type="button">' +
'<span class="sec__name">' + esc(sec.name || "Untitled section") + "</span>" +
'<span class="sec__meta">' +
'<span class="sec__dot" aria-hidden="true"></span>' +
(sec.published ? "Published" : "Draft") +
" · " + sec.blocks.length + " block" + (sec.blocks.length === 1 ? "" : "s") +
"</span></button>" +
'<button class="sec__del" type="button" aria-label="Delete ' + esc(sec.name) + '">✕</button>';
$(".sec__body", li).addEventListener("click", () => selectSection(sec.id));
$(".sec__del", li).addEventListener("click", (e) => {
e.stopPropagation();
deleteSection(sec.id);
});
wireDrag(li, $(".sec__handle", li), i);
listEl.appendChild(li);
});
$("#sectionCount").textContent =
state.sections.length + " section" + (state.sections.length === 1 ? "" : "s");
updateOutlineMeta();
}
/* ----- drag & drop reorder ----- */
let dragId = null;
function wireDrag(li, handle) {
handle.addEventListener("dragstart", (e) => {
dragId = li.dataset.id;
li.classList.add("is-dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", dragId); } catch (_) {}
});
handle.addEventListener("dragend", () => {
dragId = null;
$$(".sec", listEl).forEach((s) => s.classList.remove("is-dragging", "is-over"));
});
li.addEventListener("dragover", (e) => {
if (!dragId || li.dataset.id === dragId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
$$(".sec", listEl).forEach((s) => s.classList.remove("is-over"));
li.classList.add("is-over");
});
li.addEventListener("dragleave", () => li.classList.remove("is-over"));
li.addEventListener("drop", (e) => {
e.preventDefault();
li.classList.remove("is-over");
if (!dragId || li.dataset.id === dragId) return;
reorder(dragId, li.dataset.id);
});
// keyboard reorder: Alt+Arrow on the handle
handle.addEventListener("keydown", (e) => {
if (!e.altKey) return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const id = li.dataset.id;
moveBy(id, e.key === "ArrowUp" ? -1 : 1);
requestAnimationFrame(() => {
const moved = $('.sec[data-id="' + id + '"] .sec__handle', listEl);
if (moved) moved.focus();
});
}
});
}
function reorder(fromId, toId) {
const from = state.sections.findIndex((s) => s.id === fromId);
if (from < 0) return;
const [moved] = state.sections.splice(from, 1);
const to = state.sections.findIndex((s) => s.id === toId);
state.sections.splice(to < 0 ? state.sections.length : to, 0, moved);
renderOutline();
markDirty();
toast("Section reordered", "↕");
}
function moveBy(id, delta) {
const i = state.sections.findIndex((s) => s.id === id);
const j = i + delta;
if (i < 0 || j < 0 || j >= state.sections.length) return;
const arr = state.sections;
[arr[i], arr[j]] = [arr[j], arr[i]];
renderOutline();
markDirty();
}
/* ----- add / delete ----- */
function addSection() {
const sec = {
id: uid(),
name: "New section",
eyebrow: "Untitled",
body: "Start writing this part of the guide…",
scene: "city",
best: "Spring",
published: false,
slug: "new-section",
blocks: [],
};
state.sections.push(sec);
renderOutline();
selectSection(sec.id);
markDirty();
toast("Section added", "+");
requestAnimationFrame(() => $("#iTitle").select());
}
function deleteSection(id) {
if (state.sections.length === 1) {
toast("A guide needs at least one section", "!");
return;
}
const idx = state.sections.findIndex((s) => s.id === id);
const name = state.sections[idx].name;
state.sections.splice(idx, 1);
if (state.activeId === id) {
state.activeId = state.sections[Math.max(0, idx - 1)].id;
loadActive();
}
renderOutline();
markDirty();
toast("Deleted “" + name + "”", "🗑");
}
function selectSection(id) {
state.activeId = id;
renderOutline();
loadActive();
$("#canvas").scrollTop = 0;
}
/* =================================================================
CANVAS (center)
================================================================= */
const coverEl = $("#cover");
const blocksEl = $("#blocks");
function renderCover() {
const s = active();
coverEl.dataset.scene = s.scene;
coverEl.dataset.published = String(s.published);
$("#coverEyebrow").textContent = s.eyebrow || "Section";
if ($("#coverTitle").textContent !== s.name) $("#coverTitle").textContent = s.name;
const badge = $("#coverBadge");
badge.textContent = "Best in " + s.best;
badge.hidden = false;
}
function renderBlocks() {
const s = active();
blocksEl.innerHTML = "";
// editable intro paragraph (with drop-cap via CSS)
const intro = document.createElement("p");
intro.className = "block-intro";
intro.id = "introField";
intro.contentEditable = "true";
intro.spellcheck = true;
intro.setAttribute("role", "textbox");
intro.setAttribute("aria-label", "Section intro");
intro.textContent = s.body;
intro.addEventListener("input", () => {
s.body = intro.textContent;
$("#iBody").value = s.body;
updateStats();
updateSeo();
updateOutlineMeta();
markDirty();
});
blocksEl.appendChild(intro);
if (!s.blocks.length) {
const empty = document.createElement("div");
empty.className = "blocks__empty";
empty.textContent = "No POIs, maps or tips yet — add one below or from the inspector.";
blocksEl.appendChild(empty);
}
s.blocks.forEach((b) => blocksEl.appendChild(renderBlock(b, s)));
const addRow = document.createElement("div");
addRow.className = "block-add";
addRow.style.marginTop = "10px";
addRow.innerHTML =
'<button class="chip-btn" data-add="poi" type="button">+ POI card</button>' +
'<button class="chip-btn" data-add="map" type="button">🗺 Map block</button>' +
'<button class="chip-btn" data-add="tip" type="button">💡 Tip callout</button>';
addRow.addEventListener("click", (e) => {
const btn = e.target.closest("[data-add]");
if (btn) addBlock(btn.dataset.add);
});
blocksEl.appendChild(addRow);
}
function blockToolbar(block, s) {
const bar = document.createElement("div");
bar.className = "block__bar";
const up = mkAct("↑", "Move block up", () => moveBlock(block.id, -1));
const down = mkAct("↓", "Move block down", () => moveBlock(block.id, 1));
const del = mkAct("✕", "Remove block", () => {
s.blocks = s.blocks.filter((b) => b.id !== block.id);
renderBlocks();
renderOutline();
updateStats();
markDirty();
toast("Block removed", "🗑");
});
bar.append(up, down, del);
return bar;
}
function mkAct(label, aria, fn) {
const b = document.createElement("button");
b.className = "block__act";
b.type = "button";
b.textContent = label;
b.setAttribute("aria-label", aria);
b.title = aria;
b.addEventListener("click", fn);
return b;
}
function renderBlock(b, s) {
const wrap = document.createElement("div");
wrap.className = "block";
wrap.dataset.id = b.id;
wrap.appendChild(blockToolbar(b, s));
if (b.type === "poi") {
const card = document.createElement("div");
card.className = "poi";
card.innerHTML =
'<div class="poi__thumb" data-tone="' + (b.tone % TONES) + '" aria-hidden="true">' + poiEmoji(b.cat) + "</div>" +
'<div class="poi__main">' +
'<p class="poi__name">' + esc(b.name) + "</p>" +
'<span class="poi__cat">' + esc(b.cat) + "</span>" +
'<p class="poi__desc">' + esc(b.desc) + "</p>" +
"</div>" +
'<div class="poi__side">' +
'<span class="poi__rating">' + esc(b.rating) + "</span>" +
'<span class="poi__price">' + esc(b.price) + "</span>" +
'<button class="poi__save' + (b.saved ? " is-saved" : "") + '" type="button" aria-pressed="' + b.saved + '">' +
(b.saved ? "♥ Saved" : "♡ Save") + "</button>" +
"</div>";
$(".poi__save", card).addEventListener("click", (e) => {
b.saved = !b.saved;
const btn = e.currentTarget;
btn.classList.toggle("is-saved", b.saved);
btn.setAttribute("aria-pressed", String(b.saved));
btn.textContent = b.saved ? "♥ Saved" : "♡ Save";
toast(b.saved ? "Added “" + b.name + "” to your trip" : "Removed from trip", b.saved ? "♥" : "♡");
});
wrap.appendChild(card);
} else if (b.type === "tip") {
const tip = document.createElement("div");
tip.className = "tip";
tip.innerHTML =
'<span class="tip__icon" aria-hidden="true">💡</span>' +
'<div><span class="tip__label">Travel tip</span>' +
'<p class="tip__text" contenteditable="true" spellcheck="true" role="textbox" aria-label="Travel tip text">' +
esc(b.text) + "</p></div>";
const t = $(".tip__text", tip);
t.addEventListener("input", () => {
b.text = t.textContent;
markDirty();
});
wrap.appendChild(tip);
} else if (b.type === "map") {
const map = document.createElement("div");
map.className = "mapb";
map.innerHTML =
'<div class="mapb__head"><span>🗺 ' + esc(b.title) + "</span><span>" + b.pins.length + " stops</span></div>" +
'<div class="mapb__canvas">' + mapSvg(b.pins) + '<div class="mapb__pins">' + mapPins(b.pins) + "</div></div>" +
'<p class="mapb__note">' + esc(b.note) + "</p>";
wrap.appendChild(map);
}
return wrap;
}
function poiEmoji(cat) {
const c = cat.toLowerCase();
if (c.includes("food") || c.includes("bakery")) return "🍽";
if (c.includes("view")) return "🌅";
if (c.includes("ride")) return "🚋";
if (c.includes("design")) return "🛍";
return "📍";
}
/* SVG map mockup with a dashed route through the pins */
function mapSvg(pins) {
const route = pins.map((p) => p.x + "," + p.y).join(" ");
return (
'<svg class="mapb__svg" viewBox="0 0 100 60" preserveAspectRatio="none" aria-hidden="true">' +
'<rect width="100" height="60" fill="#eef3ee"/>' +
'<path d="M0 46 Q25 42 50 47 T100 45 L100 60 L0 60 Z" fill="#bfe0e6"/>' +
'<ellipse cx="30" cy="20" rx="14" ry="9" fill="#d6e6cf"/>' +
'<ellipse cx="78" cy="16" rx="11" ry="7" fill="#d6e6cf"/>' +
'<g stroke="#dcd6c9" stroke-width="1.2">' +
'<path d="M0 30 H100"/><path d="M0 14 H100"/><path d="M20 0 V60"/>' +
'<path d="M55 0 V60"/><path d="M82 0 V46"/>' +
"</g>" +
'<polyline points="' + route + '" fill="none" stroke="#e8623f" stroke-width="1.8" ' +
'stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 2.4"/>' +
"</svg>"
);
}
function mapPins(pins) {
return pins
.map(
(p) =>
'<div class="pin" style="left:' + p.x + "%;top:" + p.y + '%">' +
'<span class="pin__dot"><span>' + p.n + "</span></span>" +
'<span class="pin__label">' + esc(p.label) + "</span></div>"
)
.join("");
}
function moveBlock(id, delta) {
const s = active();
const i = s.blocks.findIndex((b) => b.id === id);
const j = i + delta;
if (i < 0 || j < 0 || j >= s.blocks.length) return;
[s.blocks[i], s.blocks[j]] = [s.blocks[j], s.blocks[i]];
renderBlocks();
markDirty();
}
function addBlock(kind) {
const s = active();
const b = kind === "poi" ? poiBlock(s.blocks.length) : kind === "map" ? mapBlock() : tipBlock();
s.blocks.push(b);
renderBlocks();
renderOutline();
updateStats();
markDirty();
const label = kind === "poi" ? "POI card" : kind === "map" ? "Map block" : "Travel tip";
toast(label + " inserted", "+");
requestAnimationFrame(() => {
const el = $('.block[data-id="' + b.id + '"]', blocksEl);
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
/* =================================================================
INSPECTOR (right) + bindings
================================================================= */
function loadActive() {
const s = active();
$("#inspectorTag").textContent = "Section " + (state.sections.indexOf(s) + 1);
$("#iTitle").value = s.name;
$("#iEyebrow").value = s.eyebrow;
$("#iBody").value = s.body;
$("#iPublish").checked = s.published;
$("#iSlug").value = s.slug;
$$(".scene-sw", $("#scenePick")).forEach((b) =>
b.setAttribute("aria-checked", String(b.dataset.scene === s.scene))
);
$$(".seg__btn", $("#bestSeg")).forEach((b) => {
const on = b.dataset.best === s.best;
b.classList.toggle("is-on", on);
b.setAttribute("aria-checked", String(on));
});
renderCover();
renderBlocks();
updateSeo();
updateStats();
}
function updateSeo() {
const s = active();
$("#seoSlug").textContent = s.slug;
$("#seoTitle").textContent = s.name || "Untitled section";
$("#seoDesc").textContent = s.body
? s.body.slice(0, 155) + (s.body.length > 155 ? "…" : "")
: "Add an intro to generate a description.";
const len = (s.body || "").length;
const pct = Math.min(100, Math.round((len / 160) * 100));
const bar = $("#seoBar");
bar.style.width = pct + "%";
let color = "var(--ok)", hint = "Good length";
if (len < 70) { color = "var(--gold)"; hint = "A little short — aim for 70–160 chars"; }
else if (len > 160) { color = "var(--coral)"; hint = "Trim to under 160 chars for search"; }
bar.style.background = color;
$("#seoHint").textContent = hint;
}
function updateStats() {
const s = active();
const w =
words(s.body) +
s.blocks.reduce((n, b) => n + (b.desc ? words(b.desc) : 0) + (b.text ? words(b.text) : 0), 0);
$("#statWords").textContent = w;
$("#statBlocks").textContent = s.blocks.length;
$("#statRead").textContent = Math.max(1, Math.round(w / 200)) + "m";
}
/* ----- live two-way bindings ----- */
function bindField(id, apply, afterRender) {
$("#" + id).addEventListener("input", (e) => {
apply(active(), e.target.value);
afterRender && afterRender();
markDirty();
});
}
bindField("iTitle", (s, v) => { s.name = v; }, () => {
renderCover();
if ($("#coverTitle").textContent !== active().name) $("#coverTitle").textContent = active().name;
updateNameInOutline();
updateSeo();
});
bindField("iEyebrow", (s, v) => { s.eyebrow = v; }, renderCover);
bindField("iBody", (s, v) => { s.body = v; }, () => {
const intro = $("#introField");
if (intro && intro.textContent !== active().body) intro.textContent = active().body;
updateSeo();
updateStats();
updateOutlineMeta();
});
bindField("iSlug", (s, v) => { s.slug = slugify(v); }, updateSeo);
$("#iSlug").addEventListener("blur", (e) => { e.target.value = active().slug; });
// contenteditable cover title -> inspector + model
$("#coverTitle").addEventListener("input", () => {
const s = active();
s.name = $("#coverTitle").textContent;
$("#iTitle").value = s.name;
updateNameInOutline();
updateSeo();
markDirty();
});
$("#coverTitle").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
});
function updateNameInOutline() {
const li = $('.sec[data-id="' + state.activeId + '"]');
if (li) $(".sec__name", li).textContent = active().name || "Untitled section";
}
function updateOutlineMeta() {
const totalWords = state.sections.reduce(
(n, s) => n + words(s.body) + s.blocks.reduce((m, b) => m + (b.desc ? words(b.desc) : b.text ? words(b.text) : 0), 0),
0
);
$("#readTime").textContent = "~" + Math.max(1, Math.round(totalWords / 200)) + " min read";
}
// publish toggle
$("#iPublish").addEventListener("change", (e) => {
const s = active();
s.published = e.target.checked;
coverEl.dataset.published = String(s.published);
const li = $('.sec[data-id="' + s.id + '"]');
if (li) {
li.dataset.published = String(s.published);
const meta = $(".sec__meta", li);
meta.childNodes[1].textContent = s.published ? "Published" : "Draft";
}
markDirty();
toast(s.published ? "Section published" : "Moved to draft", s.published ? "👁" : "✎");
});
// scene swatches (radiogroup)
$("#scenePick").addEventListener("click", (e) => {
const sw = e.target.closest(".scene-sw");
if (!sw) return;
active().scene = sw.dataset.scene;
$$(".scene-sw", e.currentTarget).forEach((b) =>
b.setAttribute("aria-checked", String(b === sw))
);
renderCover();
markDirty();
});
$("#scenePick").addEventListener("keydown", (e) => arrowRadio(e, ".scene-sw"));
// best-time segment (radiogroup)
$("#bestSeg").addEventListener("click", (e) => {
const btn = e.target.closest(".seg__btn");
if (!btn) return;
active().best = btn.dataset.best;
$$(".seg__btn", e.currentTarget).forEach((b) => {
const on = b === btn;
b.classList.toggle("is-on", on);
b.setAttribute("aria-checked", String(on));
});
renderCover();
markDirty();
});
$("#bestSeg").addEventListener("keydown", (e) => arrowRadio(e, ".seg__btn"));
function arrowRadio(e, sel) {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) return;
const items = $$(sel, e.currentTarget);
const i = items.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
const next = items[(i + dir + items.length) % items.length];
next.focus();
next.click();
}
// inspector add-block chips
$$(".block-add [data-add]").forEach((btn) =>
btn.addEventListener("click", () => addBlock(btn.dataset.add))
);
$("#docTitle").textContent = state.docTitle;
/* =================================================================
TOPBAR ACTIONS
================================================================= */
$("#addSectionBtn").addEventListener("click", addSection);
$("#publishBtn").addEventListener("click", () => {
const live = state.sections.filter((s) => s.published).length;
if (!live) {
toast("Publish at least one section first", "!");
return;
}
const btn = $("#publishBtn");
btn.classList.add("is-live");
btn.textContent = "Published ✓";
toast(live + " of " + state.sections.length + " sections are live", "🌍");
setTimeout(() => {
btn.classList.remove("is-live");
btn.textContent = "Publish";
}, 2400);
});
/* ----- preview modal ----- */
const modal = $("#previewModal");
let lastFocus = null;
function openPreview() {
lastFocus = document.activeElement;
$("#previewBody").innerHTML = renderReader();
modal.hidden = false;
document.addEventListener("keydown", onModalKey);
$("#closePreview").focus();
}
function closePreview() {
modal.hidden = true;
document.removeEventListener("keydown", onModalKey);
if (lastFocus) lastFocus.focus();
}
function onModalKey(e) {
if (e.key === "Escape") { closePreview(); return; }
if (e.key === "Tab") {
const f = $$("button, [href], input, [tabindex]:not([tabindex='-1'])", modal).filter(
(el) => !el.disabled && el.offsetParent !== null
);
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
function renderReader() {
const live = state.sections.filter((s) => s.published);
if (!live.length) return '<div class="rd-empty">No published sections yet. Toggle “Published” to add one.</div>';
return live
.map((s) => {
const pois = s.blocks
.filter((b) => b.type === "poi")
.map(
(b) =>
'<div class="rd-poi"><strong>' + esc(b.name) + "</strong> <span>" +
esc(b.cat) + '</span><span class="rd-poi__r">★ ' + esc(b.rating) + "</span></div>"
)
.join("");
return (
'<div class="rd-cover" style="background:' + sceneBg(s.scene) + '">' +
'<div><span class="rd-cover__eyebrow">' + esc(s.eyebrow) + "</span>" +
"<h3>" + esc(s.name) + "</h3></div></div>" +
'<div class="rd-body"><p>' + esc(s.body) + "</p>" + pois + "</div>"
);
})
.join("");
}
function sceneBg(scene) {
return {
city: "linear-gradient(135deg,#ffd9a8,#e8916a)",
coast: "linear-gradient(135deg,#8ec9d6,#3a9ca8)",
alpine: "linear-gradient(135deg,#9ec1d2,#6f95a8)",
desert: "linear-gradient(135deg,#e9a463,#cf7b46)",
}[scene] || "linear-gradient(135deg,#ffd9a8,#e8916a)";
}
$("#previewBtn").addEventListener("click", openPreview);
$("#closePreview").addEventListener("click", closePreview);
modal.addEventListener("click", (e) => { if (e.target === modal) closePreview(); });
/* ----- ⌘/Ctrl+S = "save" feel ----- */
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
markDirty();
setTimeout(() => toast("Guide saved", "💾"), 760);
}
});
/* =================================================================
BOOT
================================================================= */
renderOutline();
loadActive();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Wander — Guide Editor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#canvas">Skip to canvas</a>
<!-- ====================== TOP BAR ====================== -->
<header class="topbar" role="banner">
<div class="brand">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M12 2C8.1 2 5 5 5 8.9 5 14 12 22 12 22s7-8 7-13.1C19 5 15.9 2 12 2Z" fill="currentColor"/>
<circle cx="12" cy="9" r="2.6" fill="#fbf7f1"/>
</svg>
</span>
<div class="brand__text">
<strong>Wander</strong>
<span>Guide Editor</span>
</div>
</div>
<div class="doc-meta" aria-live="polite">
<span class="doc-meta__title" id="docTitle">Lisboa in 72 Hours</span>
<span class="doc-meta__status" id="saveState">All changes saved</span>
</div>
<div class="topbar__actions">
<button class="btn btn--ghost" id="previewBtn" type="button">
<span aria-hidden="true">◷</span> Preview
</button>
<button class="btn btn--primary" id="publishBtn" type="button">Publish</button>
</div>
</header>
<div class="workspace">
<!-- ====================== LEFT: OUTLINE ====================== -->
<aside class="outline panel" aria-label="Guide outline">
<div class="panel__head">
<h2 class="panel__title">Outline</h2>
<button class="btn btn--mini" id="addSectionBtn" type="button" aria-label="Add section">+ Section</button>
</div>
<p class="outline__hint" id="outlineHint">Drag the handle to reorder · click to edit</p>
<ol class="section-list" id="sectionList" role="list"></ol>
<div class="outline__foot">
<span id="sectionCount">0 sections</span>
<span id="readTime">~0 min read</span>
</div>
</aside>
<!-- ====================== CENTER: CANVAS ====================== -->
<main class="canvas-wrap" id="canvas" tabindex="-1">
<article class="canvas" aria-label="Guide canvas">
<div class="cover" id="cover" data-scene="city">
<div class="cover__scene" id="coverScene" aria-hidden="true"></div>
<div class="cover__overlay">
<span class="cover__eyebrow" id="coverEyebrow">City Guide · Portugal</span>
<h1 class="cover__title" id="coverTitle" contenteditable="true" spellcheck="false" role="textbox" aria-label="Section title"></h1>
<span class="cover__badge" id="coverBadge" hidden>Best in Spring</span>
</div>
<span class="cover__draft" id="coverDraft">Draft — hidden from guide</span>
</div>
<div class="blocks" id="blocks" aria-live="polite"></div>
</article>
</main>
<!-- ====================== RIGHT: INSPECTOR ====================== -->
<aside class="inspector panel" aria-label="Section inspector">
<div class="panel__head">
<h2 class="panel__title">Inspector</h2>
<span class="inspector__tag" id="inspectorTag">Section</span>
</div>
<div class="inspector__body" id="inspectorBody">
<section class="field-group">
<label class="field">
<span class="field__label">Section title</span>
<input class="field__input" id="iTitle" type="text" autocomplete="off" />
</label>
<label class="field">
<span class="field__label">Eyebrow</span>
<input class="field__input" id="iEyebrow" type="text" autocomplete="off" />
</label>
<label class="field">
<span class="field__label">Body intro</span>
<textarea class="field__input field__textarea" id="iBody" rows="4"></textarea>
</label>
<div class="field">
<span class="field__label">Cover scene</span>
<div class="scene-pick" id="scenePick" role="radiogroup" aria-label="Cover scene">
<button class="scene-sw scene-sw--city" data-scene="city" type="button" role="radio" aria-checked="true" aria-label="City"></button>
<button class="scene-sw scene-sw--coast" data-scene="coast" type="button" role="radio" aria-checked="false" aria-label="Coast"></button>
<button class="scene-sw scene-sw--alpine" data-scene="alpine" type="button" role="radio" aria-checked="false" aria-label="Alpine"></button>
<button class="scene-sw scene-sw--desert" data-scene="desert" type="button" role="radio" aria-checked="false" aria-label="Desert"></button>
</div>
</div>
</section>
<section class="field-group">
<h3 class="field-group__title">Add block</h3>
<div class="block-add">
<button class="chip-btn" data-add="poi" type="button">+ POI card</button>
<button class="chip-btn" data-add="map" type="button">🗺 Map block</button>
<button class="chip-btn" data-add="tip" type="button">💡 Tip callout</button>
</div>
</section>
<section class="field-group">
<h3 class="field-group__title">Settings</h3>
<div class="field">
<span class="field__label">Best time to visit</span>
<div class="seg" id="bestSeg" role="radiogroup" aria-label="Best time to visit">
<button class="seg__btn is-on" data-best="Spring" type="button" role="radio" aria-checked="true">Spring</button>
<button class="seg__btn" data-best="Summer" type="button" role="radio" aria-checked="false">Summer</button>
<button class="seg__btn" data-best="Autumn" type="button" role="radio" aria-checked="false">Autumn</button>
<button class="seg__btn" data-best="Winter" type="button" role="radio" aria-checked="false">Winter</button>
</div>
</div>
<label class="toggle">
<input type="checkbox" id="iPublish" />
<span class="toggle__track" aria-hidden="true"><span class="toggle__thumb"></span></span>
<span class="toggle__text">Published in guide</span>
</label>
</section>
<section class="field-group">
<h3 class="field-group__title">SEO preview</h3>
<label class="field">
<span class="field__label">URL slug</span>
<span class="slug-wrap">
<span class="slug-pre">wander.guide / lisboa /</span>
<input class="field__input slug-in" id="iSlug" type="text" autocomplete="off" />
</span>
</label>
<div class="seo">
<span class="seo__url">wander.guide / lisboa / <span id="seoSlug">arrival</span></span>
<span class="seo__title" id="seoTitle">Arrival & Alfama</span>
<p class="seo__desc" id="seoDesc">A practical, walkable opener for your first day in Lisboa.</p>
<div class="seo__meter"><span class="seo__meter-bar" id="seoBar"></span></div>
<span class="seo__hint" id="seoHint">Good length</span>
</div>
</section>
<div class="insp-stats">
<div class="stat"><strong id="statWords">0</strong><span>words</span></div>
<div class="stat"><strong id="statBlocks">0</strong><span>blocks</span></div>
<div class="stat"><strong id="statRead">0m</strong><span>read</span></div>
</div>
</div>
</aside>
</div>
<!-- toast region -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<!-- preview modal -->
<div class="modal" id="previewModal" role="dialog" aria-modal="true" aria-labelledby="previewHead" hidden>
<div class="modal__panel">
<header class="modal__head">
<h2 id="previewHead">Reader preview</h2>
<button class="btn btn--mini" id="closePreview" type="button" aria-label="Close preview">✕</button>
</header>
<div class="modal__body" id="previewBody"></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Guide Editor
A productive, content-tool layout for assembling a destination guide. The left outline
lists every section with a drag handle (or Alt + ↑/↓ for keyboard reorder), a published /
draft dot, a block count, and add / delete affordances. Clicking a section loads it into the
canvas and inspector; the topbar shows a live “Saving… / All changes saved” indicator.
The center canvas is a magazine-style page. The cover swaps between four CSS-painted scenes (city, coast, alpine, desert), carries a “Best in …” badge, and its title is directly contenteditable. Below the intro paragraph you stream in blocks: POI cards with rating, price tier and a working save-to-trip heart, a map block rendered as an inline SVG with a dashed route through numbered pins, and tip callouts you can edit in place. Each block has a hover toolbar to move or remove it.
The right inspector mirrors and edits the active section — title, eyebrow, intro, cover scene, best-time segment, a publish switch, and a slug-driven SEO preview with a length meter. Edits flow both ways between the inspector and the canvas, the outline updates as you type, and Preview opens a reader modal that renders only the published sections.
Illustrative travel UI only — fictional destinations, prices, and maps.