Real Estate — Listing Editor
An editorial listing editor for a fictional residential brokerage, built in vanilla HTML, CSS and JavaScript. Compose a property from a drag-to-reorder photo grid with an automatic cover badge, address, list price, beds, baths, square footage, property type, year built and a character-counted description, then tick a features checklist. A sticky preview card rebuilds live as you type — simulated architectural photography, price and status badges, a spec row, feature chips and a listing agent. Save and publish run inline validation and a confirmation toast.
MCP
コード
:root {
--ivory: #f7f4ec;
--paper: #fffdf8;
--white: #ffffff;
--green: #1f3d34;
--green-d: #16302a;
--green-700: #26493e;
--green-50: #e8efea;
--brass: #b08d57;
--brass-d: #94733f;
--brass-50: #f3ead9;
--ink: #1c2a25;
--ink-2: #33433d;
--muted: #6b7a72;
--line: rgba(31, 61, 52, 0.12);
--line-2: rgba(31, 61, 52, 0.22);
--ok: #2f9e6f;
--warn: #c98a2b;
--danger: #c4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-1: 0 1px 2px rgba(28, 42, 37, 0.06), 0 1px 3px rgba(28, 42, 37, 0.05);
--sh-2: 0 6px 18px rgba(28, 42, 37, 0.08), 0 2px 6px rgba(28, 42, 37, 0.05);
--sh-3: 0 22px 48px rgba(22, 48, 42, 0.16), 0 8px 18px rgba(22, 48, 42, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, sans-serif;
line-height: 1.55;
color: var(--ink);
background:
radial-gradient(1200px 600px at 80% -10%, rgba(176, 141, 87, 0.08), transparent 60%),
var(--ivory);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 {
font-family: "Cormorant Garamond", Georgia, serif;
margin: 0;
letter-spacing: 0.2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.page {
max-width: 1240px;
margin: 0 auto;
padding: 28px 24px 72px;
}
/* ---------- Masthead ---------- */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
padding-bottom: 20px;
margin-bottom: 26px;
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand__mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: linear-gradient(150deg, var(--green-700), var(--green-d));
color: var(--brass);
font-size: 22px;
box-shadow: var(--sh-2);
}
.brand__kicker {
margin: 0;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.brand__title {
font-size: clamp(28px, 4vw, 38px);
font-weight: 600;
color: var(--green);
line-height: 1.05;
}
.masthead__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
border: 1px solid var(--line-2);
background: var(--paper);
color: var(--ink-2);
}
.status-pill__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.status-pill[data-state="draft"] .status-pill__dot { background: var(--warn); }
.status-pill[data-state="published"] {
border-color: rgba(47, 158, 111, 0.4);
background: rgba(47, 158, 111, 0.1);
color: var(--green);
}
.status-pill[data-state="published"] .status-pill__dot { background: var(--ok); }
/* ---------- Buttons ---------- */
.btn {
font: inherit;
font-weight: 600;
font-size: 14px;
border-radius: var(--r-sm);
padding: 10px 16px;
cursor: pointer;
border: 1px solid transparent;
transition: transform 0.12s ease, box-shadow 0.18s ease, background 0.18s ease, color 0.18s ease;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 2px;
}
.btn--ghost {
background: var(--paper);
border-color: var(--line-2);
color: var(--green);
}
.btn--ghost:hover { border-color: var(--green); box-shadow: var(--sh-1); }
.btn--solid {
background: linear-gradient(150deg, var(--green-700), var(--green-d));
color: var(--paper);
box-shadow: var(--sh-2);
}
.btn--solid:hover { box-shadow: var(--sh-3); }
/* ---------- Layout ---------- */
.editor {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: 28px;
align-items: start;
}
.panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 22px 22px 24px;
box-shadow: var(--sh-1);
margin-bottom: 22px;
}
.panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.panel__title {
font-size: 23px;
font-weight: 600;
color: var(--green);
}
.panel__hint {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
/* ---------- Uploader ---------- */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
padding: 22px;
border: 1.5px dashed var(--line-2);
border-radius: var(--r-md);
background: linear-gradient(var(--white), var(--ivory));
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease;
margin-bottom: 16px;
}
.dropzone:hover,
.dropzone:focus-visible,
.dropzone.is-over {
border-color: var(--brass);
background: var(--brass-50);
outline: none;
}
.dropzone__icon {
font-size: 26px;
color: var(--brass-d);
line-height: 1;
}
.dropzone__title { font-weight: 600; color: var(--ink); }
.dropzone__sub { font-size: 12px; color: var(--muted); }
.photo-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
}
.photo {
position: relative;
aspect-ratio: 4 / 3;
border-radius: var(--r-md);
overflow: hidden;
border: 1px solid var(--line);
box-shadow: var(--sh-1);
cursor: grab;
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.photo:hover { box-shadow: var(--sh-2); transform: translateY(-2px); }
.photo.dragging { opacity: 0.45; cursor: grabbing; }
.photo.drop-target { outline: 2px solid var(--brass); outline-offset: 2px; }
.photo__img { position: absolute; inset: 0; }
.photo__cover {
position: absolute;
top: 8px;
left: 8px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 999px;
background: var(--green-d);
color: var(--brass);
box-shadow: var(--sh-1);
}
.photo__remove {
position: absolute;
top: 6px;
right: 6px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
border: none;
cursor: pointer;
background: rgba(28, 42, 37, 0.62);
color: #fff;
font-size: 15px;
line-height: 1;
opacity: 0;
transition: opacity 0.16s ease, background 0.16s ease;
}
.photo:hover .photo__remove,
.photo__remove:focus-visible { opacity: 1; }
.photo__remove:hover { background: var(--danger); }
.photo__order {
position: absolute;
bottom: 8px;
left: 8px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: rgba(28, 42, 37, 0.55);
padding: 2px 7px;
border-radius: 999px;
}
/* ---------- Fields ---------- */
.grid { display: grid; gap: 16px; }
.grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field--full { grid-column: 1 / -1; }
.field__label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.input {
font: inherit;
font-size: 15px;
width: 100%;
padding: 11px 13px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--white);
color: var(--ink);
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.input::placeholder { color: #9aa8a0; }
.input:hover { border-color: var(--green-700); }
.input:focus {
outline: none;
border-color: var(--green);
box-shadow: 0 0 0 3px rgba(31, 61, 52, 0.12);
}
.input.is-invalid {
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(196, 80, 62, 0.14);
}
.textarea { resize: vertical; min-height: 120px; line-height: 1.6; }
.input-money { position: relative; }
.input-money__sign {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
font-weight: 600;
pointer-events: none;
}
.input--money { padding-left: 26px; }
.select-wrap { position: relative; }
.select-wrap::after {
content: "▾";
position: absolute;
right: 13px;
top: 50%;
transform: translateY(-50%);
color: var(--brass-d);
pointer-events: none;
font-size: 12px;
}
.select-wrap .input { appearance: none; -webkit-appearance: none; padding-right: 32px; }
.field__error {
margin: 0;
font-size: 12px;
font-weight: 500;
color: var(--danger);
}
.char-count {
font-size: 12px;
font-weight: 600;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.char-count.is-near { color: var(--warn); }
.char-count.is-max { color: var(--danger); }
/* ---------- Features ---------- */
.features {
border: 0;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.feature {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--white);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: border-color 0.16s ease, background 0.16s ease;
}
.feature:hover { border-color: var(--brass); }
.feature:has(input:checked) {
border-color: var(--green);
background: var(--green-50);
color: var(--green-d);
}
.feature:has(input:focus-visible) {
outline: 2px solid var(--brass);
outline-offset: 2px;
}
.feature input {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 5px;
border: 1.5px solid var(--line-2);
background: var(--white);
display: grid;
place-items: center;
flex: none;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease;
}
.feature input::after {
content: "✓";
font-size: 12px;
color: #fff;
transform: scale(0);
transition: transform 0.14s ease;
}
.feature input:checked {
background: var(--green);
border-color: var(--green);
}
.feature input:checked::after { transform: scale(1); }
/* ---------- Preview ---------- */
.preview__sticky { position: sticky; top: 24px; }
.preview__eyebrow {
margin: 0 0 12px;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 600;
color: var(--brass-d);
}
.listing-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--sh-3);
}
.listing-card__media {
position: relative;
aspect-ratio: 4 / 3;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.22)),
linear-gradient(150deg, #c9b48f 0%, #a98c63 42%, #6f6450 100%);
}
/* simulated property "photos" — warm architectural tones */
.listing-card__media[data-tone="0"] {
background:
radial-gradient(120% 90% at 18% 12%, rgba(255, 246, 224, 0.55), transparent 55%),
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(22, 36, 30, 0.35)),
linear-gradient(155deg, #d7c6a1 0%, #b79a6d 45%, #6c5e44 100%);
}
.listing-card__media[data-tone="1"] {
background:
radial-gradient(120% 90% at 80% 18%, rgba(255, 236, 200, 0.5), transparent 55%),
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(22, 36, 30, 0.4)),
linear-gradient(150deg, #8fb6a3 0%, #4f7a68 48%, #26493e 100%);
}
.listing-card__media[data-tone="2"] {
background:
radial-gradient(110% 80% at 30% 80%, rgba(255, 222, 188, 0.5), transparent 55%),
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(22, 36, 30, 0.42)),
linear-gradient(160deg, #caa97f 0%, #9a7a52 45%, #3f3a30 100%);
}
.listing-card__media[data-tone="3"] {
background:
radial-gradient(120% 90% at 70% 30%, rgba(214, 230, 222, 0.5), transparent 55%),
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(22, 36, 30, 0.4)),
linear-gradient(150deg, #aeb9ad 0%, #6e7d6f 50%, #2e3b33 100%);
}
/* faux skyline / structure */
.listing-card__media::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(to top, rgba(22, 36, 30, 0.4), transparent 40%),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0 2px, transparent 2px 22px);
opacity: 0.7;
}
.listing-card__cover-tag,
.listing-card__status,
.listing-card__price {
position: absolute;
z-index: 1;
font-weight: 600;
}
.listing-card__cover-tag {
top: 12px;
left: 12px;
font-size: 11px;
font-variant-numeric: tabular-nums;
padding: 4px 9px;
border-radius: 999px;
background: rgba(22, 36, 30, 0.55);
color: #fff;
backdrop-filter: blur(2px);
}
.listing-card__status {
top: 12px;
right: 12px;
font-size: 10.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: 999px;
background: var(--brass);
color: var(--green-d);
box-shadow: var(--sh-1);
}
.listing-card__status[data-state="published"] {
background: var(--ok);
color: #fff;
}
.listing-card__price {
bottom: 12px;
left: 12px;
font-family: "Cormorant Garamond", Georgia, serif;
font-size: 27px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.45);
}
.listing-card__body { padding: 18px 18px 20px; }
.listing-card__title {
font-size: 25px;
font-weight: 600;
color: var(--green);
line-height: 1.1;
}
.listing-card__loc {
margin: 2px 0 14px;
font-size: 13.5px;
color: var(--muted);
}
.spec-row {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.spec-row li {
font-size: 13px;
color: var(--ink-2);
flex: 1 1 0;
min-width: 0;
text-align: center;
}
.spec-row li + li { border-left: 1px solid var(--line); }
.spec-row strong {
display: block;
font-family: "Cormorant Garamond", Georgia, serif;
font-size: 21px;
font-weight: 600;
color: var(--ink);
}
.listing-card__type {
margin: 0 0 8px;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--brass-d);
}
.listing-card__desc {
margin: 0 0 14px;
font-size: 13.5px;
color: var(--ink-2);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chip-row {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip-row li {
font-size: 11.5px;
font-weight: 500;
padding: 4px 10px;
border-radius: 999px;
background: var(--brass-50);
color: var(--brass-d);
border: 1px solid rgba(176, 141, 87, 0.25);
}
.chip-row__empty { color: var(--muted); background: transparent; border: 0; padding-left: 0; }
.listing-card__agent {
display: flex;
align-items: center;
gap: 11px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.avatar {
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--brass);
background: linear-gradient(150deg, var(--green-700), var(--green-d));
}
.agent__name { margin: 0; font-weight: 600; font-size: 14px; color: var(--ink); }
.agent__role { margin: 0; font-size: 12px; color: var(--muted); }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--green-d);
color: var(--paper);
padding: 13px 20px;
border-radius: var(--r-md);
font-size: 14px;
font-weight: 500;
box-shadow: var(--sh-3);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
max-width: calc(100vw - 32px);
}
.toast.is-show { opacity: 1; transform: translate(-50%, 0); }
.toast__check {
display: inline-block;
margin-right: 8px;
color: var(--brass);
font-weight: 700;
}
/* ---------- Responsive ---------- */
@media (max-width: 980px) {
.editor { grid-template-columns: 1fr; }
.preview__sticky { position: static; }
.preview { order: -1; }
}
@media (max-width: 520px) {
.page { padding: 18px 14px 56px; }
.masthead { gap: 12px; }
.masthead__actions { width: 100%; }
.masthead__actions .btn { flex: 1 1 auto; }
.status-pill { order: -1; }
.brand__title { font-size: 28px; }
.panel { padding: 18px 15px 20px; border-radius: var(--r-md); }
.grid--2 { grid-template-columns: 1fr; }
.features { grid-template-columns: 1fr; }
.photo-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
.listing-card__price { font-size: 23px; }
.listing-card__title { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}(function () {
"use strict";
/* ---------- helpers ---------- */
var $ = function (sel, ctx) { return (ctx || document).querySelector(sel); };
var byId = function (id) { return document.getElementById(id); };
var toastEl = byId("toast");
var toastTimer = null;
function toast(msg, ok) {
if (!toastEl) return;
toastEl.innerHTML =
(ok === false ? "" : '<span class="toast__check" aria-hidden="true">✓</span>') +
String(msg);
toastEl.hidden = false;
// force reflow so the transition replays
void toastEl.offsetWidth;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2600);
}
function digits(str) { return String(str == null ? "" : str).replace(/[^\d]/g, ""); }
function groupThousands(num) {
return String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/* ---------- feature checklist ---------- */
var FEATURES = [
"Chef's kitchen", "Hardwood floors", "Primary suite", "Two-car garage",
"Swimming pool", "Home office", "Solar panels", "Mountain views",
"Wine cellar", "Smart home", "Fireplace", "Walk-in closets"
];
var DEFAULT_ON = ["Chef's kitchen", "Hardwood floors", "Primary suite", "Mountain views"];
var featuresWrap = byId("features");
FEATURES.forEach(function (name, i) {
var id = "feat-" + i;
var label = document.createElement("label");
label.className = "feature";
label.setAttribute("for", id);
var input = document.createElement("input");
input.type = "checkbox";
input.id = id;
input.value = name;
input.checked = DEFAULT_ON.indexOf(name) !== -1;
var span = document.createElement("span");
span.textContent = name;
label.appendChild(input);
label.appendChild(span);
featuresWrap.appendChild(label);
input.addEventListener("change", renderFeatures);
});
/* ---------- photos ---------- */
// tone index cycles 0..3, mapping to the CSS gradient "photos"
var photoGrid = byId("photoGrid");
var fileInput = byId("fileInput");
var dropzone = byId("dropzone");
var photoSeq = 0;
var photos = []; // { id, tone, label }
var LABELS = ["Front elevation", "Great room", "Kitchen", "Primary suite",
"Garden", "Pool deck", "Office", "Aerial view"];
function addPhotos(n) {
for (var i = 0; i < n; i++) {
photos.push({
id: "p" + (photoSeq++),
tone: photos.length % 4,
label: LABELS[photoSeq % LABELS.length]
});
}
renderPhotos();
syncPreview();
}
function renderPhotos() {
photoGrid.innerHTML = "";
photos.forEach(function (p, idx) {
var li = document.createElement("li");
li.className = "photo listing-card__media";
li.setAttribute("data-tone", String(p.tone));
li.setAttribute("draggable", "true");
li.dataset.id = p.id;
var img = document.createElement("span");
img.className = "photo__img";
li.appendChild(img);
if (idx === 0) {
var cover = document.createElement("span");
cover.className = "photo__cover";
cover.textContent = "Cover";
li.appendChild(cover);
}
var ord = document.createElement("span");
ord.className = "photo__order";
ord.textContent = (idx + 1) + " · " + p.label;
li.appendChild(ord);
var rm = document.createElement("button");
rm.type = "button";
rm.className = "photo__remove";
rm.setAttribute("aria-label", "Remove photo " + (idx + 1));
rm.innerHTML = "×";
rm.addEventListener("click", function (e) {
e.stopPropagation();
photos = photos.filter(function (x) { return x.id !== p.id; });
renderPhotos();
syncPreview();
toast("Photo removed");
});
li.appendChild(rm);
attachDrag(li);
photoGrid.appendChild(li);
});
}
/* drag-reorder */
var dragId = null;
function attachDrag(li) {
li.addEventListener("dragstart", function (e) {
dragId = li.dataset.id;
li.classList.add("dragging");
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragId); }
});
li.addEventListener("dragend", function () {
li.classList.remove("dragging");
Array.prototype.forEach.call(photoGrid.children, function (c) { c.classList.remove("drop-target"); });
dragId = null;
});
li.addEventListener("dragover", function (e) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (li.dataset.id !== dragId) li.classList.add("drop-target");
});
li.addEventListener("dragleave", function () { li.classList.remove("drop-target"); });
li.addEventListener("drop", function (e) {
e.preventDefault();
li.classList.remove("drop-target");
var targetId = li.dataset.id;
if (!dragId || dragId === targetId) return;
var from = photos.findIndex(function (p) { return p.id === dragId; });
var to = photos.findIndex(function (p) { return p.id === targetId; });
if (from < 0 || to < 0) return;
var moved = photos.splice(from, 1)[0];
photos.splice(to, 0, moved);
var wasCover = to === 0;
renderPhotos();
syncPreview();
if (wasCover) toast("New cover photo set");
});
}
// dropzone interactions (simulate uploads)
dropzone.addEventListener("click", function () { fileInput.click(); });
dropzone.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener("change", function () {
var n = fileInput.files && fileInput.files.length ? fileInput.files.length : 1;
addPhotos(n);
toast(n + (n === 1 ? " photo added" : " photos added"));
fileInput.value = "";
});
["dragenter", "dragover"].forEach(function (ev) {
dropzone.addEventListener(ev, function (e) { e.preventDefault(); dropzone.classList.add("is-over"); });
});
["dragleave", "drop"].forEach(function (ev) {
dropzone.addEventListener(ev, function (e) { e.preventDefault(); dropzone.classList.remove("is-over"); });
});
dropzone.addEventListener("drop", function (e) {
var n = (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) || 1;
addPhotos(n);
toast(n + (n === 1 ? " photo added" : " photos added"));
});
/* ---------- live preview ---------- */
var els = {
address: byId("address"), city: byId("city"), zip: byId("zip"),
price: byId("price"), type: byId("type"), beds: byId("beds"),
baths: byId("baths"), sqft: byId("sqft"), year: byId("year"),
description: byId("description")
};
var p = {
media: byId("previewMedia"), count: byId("previewCount"),
status: byId("previewStatus"), price: byId("previewPrice"),
address: byId("previewAddress"), loc: byId("previewLoc"),
beds: byId("specBeds"), baths: byId("specBaths"), sqft: byId("specSqft"),
type: byId("previewType"), desc: byId("previewDesc"), features: byId("previewFeatures")
};
function fmtNum(v) {
var d = digits(v);
return d ? groupThousands(d) : "";
}
// live price formatting
els.price.addEventListener("input", function () {
var caretEnd = els.price.selectionStart === els.price.value.length;
els.price.value = fmtNum(els.price.value);
if (caretEnd) els.price.setSelectionRange(els.price.value.length, els.price.value.length);
clearError("price");
syncPreview();
});
function syncPreview() {
var addr = els.address.value.trim() || "Untitled listing";
p.address.textContent = addr;
var loc = [els.city.value.trim(), els.zip.value.trim()].filter(Boolean).join(" ");
p.loc.textContent = loc || "Location pending";
var price = digits(els.price.value);
p.price.textContent = price ? "$" + groupThousands(price) : "Price on request";
p.beds.textContent = els.beds.value || "0";
p.baths.textContent = els.baths.value || "0";
p.sqft.textContent = els.sqft.value ? groupThousands(digits(els.sqft.value)) : "—";
var typeParts = [els.type.value];
if (els.year.value) typeParts.push("Built " + els.year.value);
p.type.textContent = typeParts.join(" · ");
p.desc.textContent = els.description.value.trim() ||
"Add a description to bring this home to life.";
// photo cover + count
if (photos.length) {
p.media.setAttribute("data-tone", String(photos[0].tone));
p.count.textContent = "1 / " + photos.length;
p.count.style.display = "";
} else {
p.media.setAttribute("data-tone", "0");
p.count.textContent = "No photos";
}
}
function renderFeatures() {
var checked = Array.prototype.slice
.call(featuresWrap.querySelectorAll("input:checked"))
.map(function (i) { return i.value; });
p.features.innerHTML = "";
if (!checked.length) {
var li = document.createElement("li");
li.className = "chip-row__empty";
li.textContent = "No features selected yet";
p.features.appendChild(li);
return;
}
checked.slice(0, 8).forEach(function (name) {
var li = document.createElement("li");
li.textContent = name;
p.features.appendChild(li);
});
if (checked.length > 8) {
var more = document.createElement("li");
more.textContent = "+" + (checked.length - 8) + " more";
p.features.appendChild(more);
}
}
// wire all text/number/select inputs to preview
["address", "city", "zip", "type", "beds", "baths", "sqft", "year", "description"]
.forEach(function (k) {
els[k].addEventListener("input", function () { clearError(k); syncPreview(); });
els[k].addEventListener("change", syncPreview);
});
/* ---------- char count ---------- */
var charCount = byId("charCount");
var MAX = 600;
function updateCount() {
var len = els.description.value.length;
charCount.textContent = len + " / " + MAX;
charCount.classList.toggle("is-near", len >= MAX * 0.85 && len < MAX);
charCount.classList.toggle("is-max", len >= MAX);
}
els.description.addEventListener("input", updateCount);
/* ---------- validation ---------- */
function showError(field) {
els[field].classList.add("is-invalid");
var msg = document.querySelector('[data-error="' + field + '"]');
if (msg) msg.hidden = false;
}
function clearError(field) {
if (!els[field]) return;
els[field].classList.remove("is-invalid");
var msg = document.querySelector('[data-error="' + field + '"]');
if (msg) msg.hidden = true;
}
function validate() {
var ok = true;
if (!els.address.value.trim()) { showError("address"); ok = false; }
var price = digits(els.price.value);
if (!price || Number(price) <= 0) { showError("price"); ok = false; }
return ok;
}
/* ---------- status + buttons ---------- */
var statusPill = byId("statusPill");
var statusLabel = byId("statusLabel");
function setStatus(state) {
statusPill.setAttribute("data-state", state);
var text = state === "published" ? "Published" : "Draft";
statusLabel.textContent = text;
p.status.textContent = text;
p.status.setAttribute("data-state", state);
}
byId("saveBtn").addEventListener("click", function () {
syncPreview();
setStatus("draft");
toast("Draft saved");
});
byId("publishBtn").addEventListener("click", function () {
if (!validate()) {
var first = document.querySelector(".input.is-invalid");
if (first) first.focus();
toast("Add an address and price before publishing", false);
return;
}
if (!photos.length) {
toast("Add at least one photo before publishing", false);
dropzone.focus();
return;
}
setStatus("published");
var addr = els.address.value.trim();
toast("Published “" + addr + "” to the market");
});
/* ---------- init ---------- */
addPhotos(4);
renderFeatures();
updateCount();
syncPreview();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Real Estate — Listing 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=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead">
<div class="brand">
<span class="brand__mark" aria-hidden="true">◈</span>
<div class="brand__text">
<p class="brand__kicker">Halewood & Marsh — Brokerage Studio</p>
<h1 class="brand__title">Listing Editor</h1>
</div>
</div>
<div class="masthead__actions">
<span class="status-pill" id="statusPill" data-state="draft">
<span class="status-pill__dot" aria-hidden="true"></span>
<span id="statusLabel">Draft</span>
</span>
<button class="btn btn--ghost" id="saveBtn" type="button">Save draft</button>
<button class="btn btn--solid" id="publishBtn" type="button">Publish listing</button>
</div>
</header>
<main class="editor" aria-label="Listing editor">
<!-- LEFT: form -->
<form class="form" id="listingForm" novalidate>
<!-- Photos -->
<section class="panel" aria-labelledby="photosHd">
<div class="panel__head">
<h2 class="panel__title" id="photosHd">Photography</h2>
<p class="panel__hint">Drag to reorder · the first photo becomes the cover</p>
</div>
<div class="uploader">
<label class="dropzone" id="dropzone" tabindex="0" role="button" aria-label="Add photos to this listing">
<input type="file" id="fileInput" accept="image/*" multiple hidden />
<span class="dropzone__icon" aria-hidden="true">+</span>
<span class="dropzone__title">Add photos</span>
<span class="dropzone__sub">Drop here, or browse — demo images are simulated</span>
</label>
<ul class="photo-grid" id="photoGrid" aria-label="Listing photos, drag to reorder"></ul>
</div>
</section>
<!-- Core details -->
<section class="panel" aria-labelledby="detailsHd">
<div class="panel__head">
<h2 class="panel__title" id="detailsHd">Property details</h2>
</div>
<div class="grid grid--2">
<div class="field field--full">
<label class="field__label" for="address">Street address</label>
<input class="input" id="address" name="address" type="text"
value="118 Lantern Hill Road" placeholder="Street & number" />
<p class="field__error" data-error="address" hidden>Add a street address.</p>
</div>
<div class="field">
<label class="field__label" for="city">City & state</label>
<input class="input" id="city" name="city" type="text" value="Mill Valley, CA" placeholder="City, ST" />
</div>
<div class="field">
<label class="field__label" for="zip">ZIP</label>
<input class="input" id="zip" name="zip" type="text" inputmode="numeric" value="94941" placeholder="00000" />
</div>
<div class="field">
<label class="field__label" for="price">List price (USD)</label>
<div class="input-money">
<span class="input-money__sign" aria-hidden="true">$</span>
<input class="input input--money" id="price" name="price" type="text"
inputmode="numeric" value="2,395,000" placeholder="0" />
</div>
<p class="field__error" data-error="price" hidden>Enter a valid price.</p>
</div>
<div class="field">
<label class="field__label" for="type">Property type</label>
<div class="select-wrap">
<select class="input" id="type" name="type">
<option>Single-family home</option>
<option>Townhouse</option>
<option>Condominium</option>
<option>Loft</option>
<option>Estate / Villa</option>
<option>Land</option>
</select>
</div>
</div>
<div class="field">
<label class="field__label" for="beds">Bedrooms</label>
<input class="input" id="beds" name="beds" type="number" min="0" max="20" step="1" value="4" />
</div>
<div class="field">
<label class="field__label" for="baths">Bathrooms</label>
<input class="input" id="baths" name="baths" type="number" min="0" max="20" step="0.5" value="3.5" />
</div>
<div class="field">
<label class="field__label" for="sqft">Living area (sq ft)</label>
<input class="input" id="sqft" name="sqft" type="number" min="0" step="1" value="3120" />
</div>
<div class="field">
<label class="field__label" for="year">Year built</label>
<input class="input" id="year" name="year" type="number" min="1800" max="2030" step="1" value="1996" />
</div>
</div>
</section>
<!-- Description -->
<section class="panel" aria-labelledby="descHd">
<div class="panel__head">
<h2 class="panel__title" id="descHd">Description</h2>
<span class="char-count" id="charCount" aria-live="polite">0 / 600</span>
</div>
<textarea class="input textarea" id="description" name="description" rows="5" maxlength="600"
placeholder="Describe the home — light, finishes, the feeling of arriving.">Set behind a row of olive trees, this sun-washed contemporary pairs white-oak floors with walls of glass that open to a level garden. The chef's kitchen anchors an open great room, and the primary suite reads like a private retreat above the canopy.</textarea>
</section>
<!-- Features -->
<section class="panel" aria-labelledby="featHd">
<div class="panel__head">
<h2 class="panel__title" id="featHd">Features & amenities</h2>
<p class="panel__hint">Highlighted on the public listing</p>
</div>
<fieldset class="features" id="features">
<legend class="sr-only">Select features included with this property</legend>
</fieldset>
</section>
</form>
<!-- RIGHT: live preview -->
<aside class="preview" aria-label="Live preview">
<div class="preview__sticky">
<p class="preview__eyebrow">Live preview</p>
<article class="listing-card" id="previewCard">
<div class="listing-card__media" id="previewMedia" data-tone="0">
<span class="listing-card__cover-tag" id="previewCount">1 / 1</span>
<span class="listing-card__status" id="previewStatus">Draft</span>
<span class="listing-card__price" id="previewPrice">$2,395,000</span>
</div>
<div class="listing-card__body">
<h3 class="listing-card__title" id="previewAddress">118 Lantern Hill Road</h3>
<p class="listing-card__loc" id="previewLoc">Mill Valley, CA 94941</p>
<ul class="spec-row" id="previewSpecs">
<li><strong id="specBeds">4</strong> beds</li>
<li><strong id="specBaths">3.5</strong> baths</li>
<li><strong id="specSqft">3,120</strong> sq ft</li>
</ul>
<p class="listing-card__type" id="previewType">Single-family home · Built 1996</p>
<p class="listing-card__desc" id="previewDesc"></p>
<ul class="chip-row" id="previewFeatures"></ul>
<div class="listing-card__agent">
<span class="avatar" aria-hidden="true">RA</span>
<div>
<p class="agent__name">Rowan Albright</p>
<p class="agent__role">Listing agent · DRE #01992441</p>
</div>
</div>
</div>
</article>
</div>
</aside>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Listing Editor
A polished workspace for an agent at the fictional Halewood & Marsh brokerage to compose a property listing. The left column is the editor: a photo uploader with a drag-and-drop dropzone and a grid of simulated property “photos” you can drag to reorder, where the first tile always earns a brass Cover badge and any tile can be removed. Below it sit the core fields — street address, city and ZIP, list price with live thousands formatting, property type, beds, baths, living area and year built — followed by a description with a running character count and a two-column features-and-amenities checklist.
The right column is a sticky live preview that rebuilds on every keystroke. It renders a refined listing card: a CSS-painted architectural cover image (warm or verdant tones that follow the current cover photo), price and status badges, a tabular spec row for beds / baths / sq ft, the property type and build year, a clamped description, feature chips and a listing agent block. Reordering photos updates the cover; toggling features re-flows the chip row.
Saving stores the listing as a Draft; publishing runs lightweight validation — an address and a positive price are required, and at least one photo — highlighting any missing field, flipping the status pill and card badge to Published, and firing a confirmation toast. Every control is keyboard-usable and labelled for assistive tech.
Illustrative UI only — sample listings and data are fictional; not a real real-estate service.