Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions app.css
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,166 @@ textarea.inline-edit {
letter-spacing: 0;
}

/* ── Per-type bespoke: Recipe ── */
.stats-strip {
display: flex;
align-items: center;
gap: .5rem;
padding: .75rem 0 .35rem;
margin-bottom: .5rem;
}
.stat-chip {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: .45rem .85rem;
border-radius: 10px;
background: color-mix(in oklab, var(--accent) 7%, var(--surface));
border: 1px solid color-mix(in oklab, var(--accent) 12%, var(--line));
font-weight: 600;
font-size: .85rem;
color: var(--ink-2);
line-height: 1.3;
}
.stat-chip small {
font-size: .6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--accent);
opacity: .8;
}
.stats-sep {
width: 1px;
height: 24px;
background: var(--line);
}

.section-block {
margin: .75rem 0;
}
.section-label {
font-size: .68rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .4rem;
}
.checklist {
list-style: none;
padding: 0;
margin: 0;
}
.checklist li {
display: flex;
align-items: flex-start;
gap: .55rem;
padding: .35rem 0;
cursor: pointer;
transition: opacity .15s;
}
.check-box {
flex-shrink: 0;
width: 18px; height: 18px;
border: 2px solid color-mix(in oklab, var(--accent) 40%, var(--line));
border-radius: 4px;
margin-top: 1px;
display: grid;
place-items: center;
transition: background .15s, border-color .15s;
}
.checklist li.checked .check-box {
background: var(--accent);
border-color: var(--accent);
}
.checklist li.checked .check-box::after {
content: "\2713";
color: #fff;
font-size: .65rem;
font-weight: 700;
}
.check-text {
line-height: 1.55;
color: var(--ink-2);
transition: opacity .15s;
}
.checklist li.checked .check-text {
opacity: .45;
text-decoration: line-through;
}
.step-list {
counter-reset: step;
list-style: none;
padding: 0;
margin: 0;
}
.step-list li {
position: relative;
padding: .5rem 0 .5rem 2.2rem;
line-height: 1.6;
color: var(--ink-2);
border-left: 2px solid color-mix(in oklab, var(--accent) 20%, var(--line));
margin-left: .6rem;
}
.step-list li::before {
counter-increment: step;
content: counter(step);
position: absolute;
left: -13px;
top: .45rem;
width: 24px; height: 24px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: .7rem;
font-weight: 700;
display: grid;
place-items: center;
box-shadow: 0 1px 4px rgba(0,0,0,.12);
}

/* ── Per-type bespoke: Review ── */
.rating-hero {
display: flex;
align-items: center;
gap: .6rem;
padding: .5rem 0 .75rem;
}
.rating-hero-stars {
font-size: 1.6rem;
color: #f59e0b;
letter-spacing: .04em;
line-height: 1;
}
.rating-hero-num {
font-size: .85rem;
font-weight: 700;
color: var(--muted);
}
.review-body {
margin: .5rem 0 .75rem;
padding: 1rem 1.2rem;
border-left: 3px solid var(--accent);
border-radius: 0 10px 10px 0;
background: color-mix(in oklab, var(--accent) 4%, var(--surface));
}
.review-body p {
margin: 0;
font-size: .92rem;
line-height: 1.7;
color: var(--ink-2);
font-style: italic;
}
.review-byline {
display: block;
margin-top: .6rem;
font-size: .8rem;
font-weight: 600;
color: var(--muted);
font-style: normal;
}

/* ── Price / currency ── */
.rich-price {
font-weight: 700;
Expand Down Expand Up @@ -579,6 +739,19 @@ footer a:hover { color: var(--accent-2); }
.bd::before { opacity: .15; }
.avatar { border-color: var(--card); }
.row .k::before { opacity: .6; }
.stat-chip {
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
border-color: color-mix(in oklab, var(--accent) 18%, var(--line));
}
.check-box {
border-color: color-mix(in oklab, var(--accent) 50%, var(--line));
}
.step-list li {
border-left-color: color-mix(in oklab, var(--accent) 25%, var(--line));
}
.review-body {
background: color-mix(in oklab, var(--accent) 6%, var(--surface));
}
}

/* ──────────────────────────────────────────────
Expand Down Expand Up @@ -676,4 +849,6 @@ footer a:hover { color: var(--accent-2); }
.bd::before { display: none; }
.hero { height: 170px; }
.avatar { width: 76px; height: 76px; margin-top: -38px; }
.rating-hero-stars { font-size: 1.3rem; }
.stat-chip { padding: .35rem .65rem; font-size: .8rem; }
}
116 changes: 116 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,19 @@
}
}

// Per-type bespoke layouts β€” set of props handled by bespoke rendering
// so the generic row loop skips them
const bespokeProps = renderTypeSpecific(view, heroRendered);

for (const f of cfg.fields) {
if (f.prop === cfg.titleProp) continue;
const val = data[f.prop];
if (val === undefined || val === "") continue;
// Skip image fields already rendered as hero
const isImage = f.type === "image" || IMAGE_PROPS.includes(f.prop) || isImageUrl(val);
if (isImage && heroRendered) continue;
// Skip props already rendered by bespoke layout
if (bespokeProps.has(f.prop)) continue;
const row = document.createElement("div"); row.className = "row";
const k = document.createElement("span"); k.className = "k"; k.textContent = f.label;
const v = document.createElement("span"); v.className = "v";
Expand All @@ -260,6 +266,116 @@
attachHeroEditor(heroMount);
}

// ── Per-type bespoke layouts ──
// Returns a Set of prop names that were handled, so the generic loop can skip them.
function renderTypeSpecific(view, heroRendered) {
const handled = new Set();

// ── RECIPE ──
if (cfg.type === "Recipe") {
// Stats strip: prep, cook, yield
const stats = [];
const prep = data["prepTime"];
if (prep) { const d = formatDuration(prep); if (d) stats.push({ label: "Prep", val: d }); }
const cook = data["cookTime"];
if (cook) { const d = formatDuration(cook); if (d) stats.push({ label: "Cook", val: d }); }
const yield_ = data["recipeYield"];
if (yield_) stats.push({ label: "Yield", val: yield_ });

if (stats.length) {
const strip = document.createElement("div"); strip.className = "stats-strip";
stats.forEach((s, i) => {
if (i > 0) { const sep = document.createElement("span"); sep.className = "stats-sep"; strip.appendChild(sep); }
const chip = document.createElement("span"); chip.className = "stat-chip";
chip.innerHTML = "<small>" + s.label + "</small>" + s.val;
strip.appendChild(chip);
});
view.appendChild(strip);
}
if (prep) handled.add("prepTime");
if (cook) handled.add("cookTime");
if (yield_) handled.add("recipeYield");

// Ingredients checklist
const ingredients = data["recipeIngredient"];
if (ingredients) {
const items = typeof ingredients === "string" ? ingredients.split(/,(?=[\s])/) : (Array.isArray(ingredients) ? ingredients : [ingredients]);
const section = document.createElement("div"); section.className = "section-block";
const h = document.createElement("div"); h.className = "section-label"; h.textContent = "Ingredients";
section.appendChild(h);
const ul = document.createElement("ul"); ul.className = "checklist";
items.forEach((item) => {
const li = document.createElement("li");
const cb = document.createElement("span"); cb.className = "check-box";
const txt = document.createElement("span"); txt.className = "check-text"; txt.textContent = item.trim();
li.append(cb, txt);
li.addEventListener("click", () => li.classList.toggle("checked"));
ul.appendChild(li);
});
section.appendChild(ul);
view.appendChild(section);
handled.add("recipeIngredient");
}

// Instructions numbered list
const instr = data["recipeInstructions"];
if (instr) {
const steps = typeof instr === "string" ? instr.split(/\n|\.\s+(?=[A-Z])/).filter(s => s.trim()) : (Array.isArray(instr) ? instr : [instr]);
const section = document.createElement("div"); section.className = "section-block";
const h = document.createElement("div"); h.className = "section-label"; h.textContent = "Instructions";
section.appendChild(h);
const ol = document.createElement("ol"); ol.className = "step-list";
steps.forEach((step) => {
const li = document.createElement("li"); li.textContent = step.trim().replace(/^\d+\.\s*/, "");
ol.appendChild(li);
});
section.appendChild(ol);
view.appendChild(section);
handled.add("recipeInstructions");
}
}

// ── REVIEW ──
if (cfg.type === "Review") {
// Large prominent rating near top
const ratingVal = data["reviewRating"];
if (ratingVal !== undefined) {
const r = formatRating(ratingVal, "reviewRating");
if (r) {
const ratingBlock = document.createElement("div"); ratingBlock.className = "rating-hero";
let stars = "";
for (let i = 0; i < r.full; i++) stars += "\u2605";
if (r.half) stars += "\u00BD";
for (let i = 0; i < r.empty; i++) stars += "\u2606";
const starsSpan = document.createElement("span"); starsSpan.className = "rating-hero-stars"; starsSpan.textContent = stars;
const numSpan = document.createElement("span"); numSpan.className = "rating-hero-num"; numSpan.textContent = r.raw + "/" + r.max;
ratingBlock.append(starsSpan, numSpan);
view.appendChild(ratingBlock);
}
handled.add("reviewRating");
}

// Review body as blockquote with author byline
const body = data["reviewBody"];
if (body) {
const bq = document.createElement("blockquote"); bq.className = "review-body";
const p = document.createElement("p"); p.textContent = body;
bq.appendChild(p);
const author = data["author"];
if (author) {
const cite = document.createElement("cite"); cite.className = "review-byline";
cite.textContent = "\u2014 " + author;
bq.appendChild(cite);
}
view.appendChild(bq);
handled.add("reviewBody");
if (author) handled.add("author");
}
}

return handled;
}

// ── Inline click-to-edit ──
function attachInlineEditors(container) {
container.querySelectorAll(".v[data-prop]").forEach((v) => {
Expand Down
Loading