Cookbook — Food Blog Dashboard (traffic · top recipes)
A warm, editorial admin dashboard for a food blog, built with plain HTML, CSS and vanilla JavaScript. A sidebar nav frames a KPI row for page views, unique visitors, average time on a recipe and saves, each with deltas and live sparklines. An inline-SVG traffic chart redraws on a seven, thirty or ninety day toggle, while a sortable top-recipes table and an approve-or-reject comment feed round out a cream-and-tomato kitchen interface.
MCP
Code
:root {
--cream: #faf6ef;
--paper: #fffdf8;
--ink: #2b2622;
--ink-2: #5c534a;
--muted: #8a7f73;
--tomato: #d6452b;
--tomato-d: #b8351e;
--saffron: #e8a33d;
--sage: #7c8a6b;
--clay: #c8775a;
--line: rgba(43, 38, 34, 0.12);
--line-2: rgba(43, 38, 34, 0.2);
--ok: #3f8f5f;
--warn: #d98a2b;
--danger: #c8412b;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-sm: 0 1px 2px rgba(43, 38, 34, 0.1);
--sh-lg: 0 10px 30px rgba(43, 38, 34, 0.1);
--serif: "Fraunces", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
font-family: var(--sans);
background: var(--cream);
color: var(--ink);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
:focus-visible {
outline: 2.5px solid var(--tomato);
outline-offset: 2px;
border-radius: 4px;
}
/* Layout */
.app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
background: var(--paper);
border-right: 1px solid var(--line);
padding: 22px 18px;
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--serif);
font-weight: 700;
font-size: 1.25rem;
letter-spacing: -0.01em;
}
.brand-mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 12px;
background: radial-gradient(circle at 30% 25%, #ffd9a0, var(--saffron) 60%, var(--tomato));
box-shadow: var(--sh-sm);
font-size: 1.1rem;
}
.nav {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: var(--r-sm);
color: var(--ink-2);
text-decoration: none;
font-weight: 500;
font-size: 0.92rem;
transition: background 0.16s, color 0.16s;
}
.nav-item span[aria-hidden] { font-size: 1rem; }
.nav-item:hover { background: rgba(214, 69, 43, 0.07); color: var(--ink); }
.nav-item.is-active {
background: linear-gradient(90deg, rgba(214, 69, 43, 0.14), rgba(232, 163, 61, 0.1));
color: var(--tomato-d);
font-weight: 600;
}
.nav-badge {
margin-left: auto;
background: var(--tomato);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
}
.sidebar-card {
margin-top: auto;
background: linear-gradient(160deg, #f3ede1, #fbf6ec);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px;
}
.sidebar-card-photo {
width: 100%;
height: 56px;
border-radius: var(--r-sm);
background:
radial-gradient(circle at 20% 30%, rgba(124, 138, 107, 0.85), transparent 45%),
radial-gradient(circle at 75% 65%, rgba(232, 163, 61, 0.7), transparent 50%),
linear-gradient(135deg, #8fa07a, #c8a85a);
display: grid;
place-items: center;
font-size: 1.5rem;
margin-bottom: 12px;
}
.sidebar-card-title { margin: 0; font-weight: 600; font-size: 0.9rem; }
.sidebar-card-sub { margin: 2px 0 10px; font-size: 0.78rem; color: var(--muted); }
.meter {
height: 7px;
background: rgba(43, 38, 34, 0.1);
border-radius: 999px;
overflow: hidden;
}
.meter span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--saffron), var(--tomato));
}
/* Main */
.main {
padding: 30px 36px 48px;
max-width: 1180px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 26px;
flex-wrap: wrap;
}
.kicker {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.7rem;
font-weight: 700;
color: var(--clay);
}
.page-title {
font-family: var(--serif);
font-weight: 600;
font-size: 1.9rem;
margin: 4px 0 2px;
letter-spacing: -0.02em;
}
.page-sub { margin: 0; color: var(--muted); font-size: 0.92rem; }
.range {
display: inline-flex;
background: var(--paper);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
box-shadow: var(--sh-sm);
}
.range-btn {
border: 0;
background: transparent;
font-family: var(--sans);
font-weight: 600;
font-size: 0.85rem;
color: var(--ink-2);
padding: 6px 16px;
border-radius: 999px;
cursor: pointer;
transition: background 0.16s, color 0.16s;
}
.range-btn:hover { color: var(--ink); }
.range-btn.is-active {
background: var(--ink);
color: var(--paper);
}
/* KPIs */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 22px;
}
.kpi {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px;
box-shadow: var(--sh-sm);
position: relative;
overflow: hidden;
}
.kpi::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(var(--saffron), var(--tomato));
}
.kpi-label {
margin: 0;
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.kpi-value {
font-family: var(--serif);
font-weight: 600;
font-size: 1.7rem;
margin: 6px 0 8px;
letter-spacing: -0.02em;
}
.kpi-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.delta {
font-size: 0.82rem;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 3px;
}
.delta.up { color: var(--ok); }
.delta.down { color: var(--danger); }
.spark {
width: 84px;
height: 26px;
flex: none;
}
/* Grid panels */
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 18px;
}
.panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 22px;
box-shadow: var(--sh-sm);
}
.chart-panel { grid-column: 1; grid-row: 1; }
.comments-panel { grid-column: 2; grid-row: 1 / span 2; }
.table-panel { grid-column: 1; grid-row: 2; }
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel-title {
font-family: var(--serif);
font-weight: 600;
font-size: 1.2rem;
margin: 0;
letter-spacing: -0.01em;
}
.panel-sub { margin: 2px 0 0; font-size: 0.82rem; color: var(--muted); }
.pill {
background: rgba(232, 163, 61, 0.18);
color: var(--warn);
font-size: 0.74rem;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
white-space: nowrap;
}
.legend { display: flex; gap: 14px; }
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--ink-2);
font-weight: 500;
}
.dot { width: 9px; height: 9px; border-radius: 3px; display: inline-block; }
.dot-views { background: var(--tomato); }
.dot-visitors { background: var(--sage); }
/* Chart */
.chart-wrap { position: relative; }
#chart {
width: 100%;
height: 240px;
display: block;
}
#chart .grid-line { stroke: var(--line); stroke-width: 1; }
#chart .axis-label { fill: var(--muted); font-size: 11px; font-family: var(--sans); }
#chart .area-views { fill: url(#gViews); }
#chart .area-visitors { fill: url(#gVisitors); }
#chart .line-views { fill: none; stroke: var(--tomato); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
#chart .line-visitors { fill: none; stroke: var(--sage); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
#chart .hover-dot { fill: var(--paper); stroke-width: 2.5; }
#chart .hover-line { stroke: var(--line-2); stroke-width: 1; stroke-dasharray: 3 3; }
.chart-tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: var(--ink);
color: var(--paper);
font-size: 0.74rem;
font-weight: 500;
padding: 7px 10px;
border-radius: var(--r-sm);
pointer-events: none;
white-space: nowrap;
box-shadow: var(--sh-lg);
z-index: 5;
}
.chart-tooltip strong { font-weight: 700; }
.chart-tooltip .tt-date { color: #d8cdbf; display: block; margin-bottom: 3px; font-size: 0.68rem; }
.chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
/* Comments */
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.comment {
display: flex;
gap: 11px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
animation: fadein 0.3s ease;
}
.comment:last-child { border-bottom: 0; padding-bottom: 0; }
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
font-weight: 700;
color: #fff;
font-size: 0.85rem;
}
.comment-body { flex: 1; min-width: 0; }
.comment-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.comment-name { font-weight: 600; font-size: 0.88rem; }
.comment-on { font-size: 0.76rem; color: var(--muted); }
.comment-on em { font-style: normal; color: var(--clay); font-weight: 500; }
.comment-text { margin: 4px 0 8px; font-size: 0.86rem; color: var(--ink-2); }
.comment-actions { display: flex; gap: 8px; }
.btn-mini {
border: 1px solid var(--line-2);
background: var(--paper);
font-family: var(--sans);
font-weight: 600;
font-size: 0.76rem;
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
transition: all 0.15s;
}
.btn-mini.approve:hover { background: var(--ok); border-color: var(--ok); color: #fff; }
.btn-mini.reject:hover { background: var(--danger); border-color: var(--danger); color: #fff; }
.comment.resolved { opacity: 0.55; }
.comment-status {
font-size: 0.74rem;
font-weight: 700;
}
.comment-status.approved { color: var(--ok); }
.comment-status.rejected { color: var(--danger); }
/* Table */
.table-scroll { overflow-x: auto; }
.recipes {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.recipes th, .recipes td {
text-align: left;
padding: 11px 12px;
border-bottom: 1px solid var(--line);
}
.recipes th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 700;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.recipes th:hover { color: var(--ink); }
.recipes th.num, .recipes td.num { text-align: right; }
.recipes tbody tr { transition: background 0.14s; }
.recipes tbody tr:hover { background: rgba(232, 163, 61, 0.08); }
.caret { display: inline-block; width: 0; height: 0; margin-left: 4px; opacity: 0; vertical-align: middle; }
th[aria-sort="ascending"] .caret { opacity: 1; border-left: 4px solid transparent; border-right: 4px solid transparent; border-bottom: 5px solid var(--tomato); }
th[aria-sort="descending"] .caret { opacity: 1; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid var(--tomato); }
.recipe-cell { display: flex; align-items: center; gap: 11px; }
.recipe-thumb {
width: 40px;
height: 40px;
border-radius: 10px;
flex: none;
display: grid;
place-items: center;
font-size: 1.1rem;
box-shadow: var(--sh-sm);
}
.recipe-name { font-weight: 600; color: var(--ink); }
.recipe-meta { font-size: 0.74rem; color: var(--muted); }
.stars { color: var(--saffron); letter-spacing: 1px; }
.rating-val { font-weight: 700; }
.trend {
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 3px;
}
.trend.up { color: var(--ok); }
.trend.down { color: var(--danger); }
.trend.flat { color: var(--muted); }
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--ink);
color: var(--paper);
padding: 11px 20px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 500;
box-shadow: var(--sh-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@keyframes fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* Responsive */
@media (max-width: 980px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
.grid { grid-template-columns: 1fr; }
.chart-panel, .comments-panel, .table-panel { grid-column: 1; grid-row: auto; }
}
@media (max-width: 720px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.nav { flex-direction: row; flex-wrap: wrap; flex: 1; }
.nav-item { padding: 8px 10px; }
.nav-badge { margin-left: 6px; }
.sidebar-card { display: none; }
.main { padding: 22px 18px 40px; }
}
@media (max-width: 480px) {
.kpis { grid-template-columns: 1fr; }
.page-title { font-size: 1.55rem; }
}/* Cookbook — Food Blog Dashboard
Vanilla JS. Date-range recomputes KPIs + redraws the inline-SVG chart,
sortable top-recipes table, comment approve/reject. Fictional data. */
(function () {
"use strict";
var SVGNS = "http://www.w3.org/2000/svg";
/* ---------- Toast helper ---------- */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
/* ---------- Deterministic pseudo-random for plausible series ---------- */
function makeSeries(seed, len, base, spread, drift) {
var out = [];
var s = seed;
for (var i = 0; i < len; i++) {
s = (s * 9301 + 49297) % 233280;
var r = s / 233280;
var wave = Math.sin(i / 3.3) * 0.35 + Math.sin(i / 1.6) * 0.18;
var val = base + drift * i + spread * (r - 0.5) * 2 + spread * wave;
out.push(Math.max(0, Math.round(val)));
}
return out;
}
/* ---------- Range datasets (7 / 30 / 90 days) ---------- */
var RANGES = {
7: {
label: "last 7 days",
views: makeSeries(11, 7, 4200, 900, 60),
visitors: makeSeries(23, 7, 2600, 520, 40),
kpis: { views: 31480, visitors: 18920, time: 254, saves: 1342 },
deltas: { views: 8.4, visitors: 6.1, time: 3.2, saves: 11.7 }
},
30: {
label: "last 30 days",
views: makeSeries(31, 30, 3900, 1100, 22),
visitors: makeSeries(47, 30, 2450, 640, 14),
kpis: { views: 142730, visitors: 86410, time: 248, saves: 5870 },
deltas: { views: 12.6, visitors: 9.3, time: -1.4, saves: 14.2 }
},
90: {
label: "last 90 days",
views: makeSeries(67, 90, 3700, 1300, 9),
visitors: makeSeries(83, 90, 2300, 760, 6),
kpis: { views: 408960, visitors: 241300, time: 241, saves: 16480 },
deltas: { views: 19.1, visitors: 15.8, time: 2.0, saves: 22.5 }
}
};
var currentRange = 30;
/* ---------- Formatters ---------- */
function fmtInt(n) { return n.toLocaleString("en-US"); }
function fmtTime(sec) {
var m = Math.floor(sec / 60);
var s = sec % 60;
return m + "m " + (s < 10 ? "0" + s : s) + "s";
}
function fmtValue(kpi, n) {
if (kpi === "time") return fmtTime(n);
return fmtInt(n);
}
/* ---------- Sparklines ---------- */
function buildPath(values, w, h, pad) {
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var n = values.length;
var pts = values.map(function (v, i) {
var x = pad + (i / (n - 1)) * (w - pad * 2);
var y = h - pad - ((v - min) / range) * (h - pad * 2);
return [x, y];
});
var d = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (var i = 1; i < pts.length; i++) {
d += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
}
return { d: d, pts: pts };
}
function renderSpark(svg, values, up) {
svg.innerHTML = "";
var w = 100, h = 28, pad = 3;
var p = buildPath(values, w, h, pad);
var color = up ? "var(--ok)" : "var(--danger)";
var line = document.createElementNS(SVGNS, "path");
line.setAttribute("d", p.d);
line.setAttribute("fill", "none");
line.setAttribute("stroke", color);
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-linejoin", "round");
line.setAttribute("stroke-linecap", "round");
svg.appendChild(line);
var last = p.pts[p.pts.length - 1];
var dot = document.createElementNS(SVGNS, "circle");
dot.setAttribute("cx", last[0]);
dot.setAttribute("cy", last[1]);
dot.setAttribute("r", "2.4");
dot.setAttribute("fill", color);
svg.appendChild(dot);
}
/* ---------- KPI render ---------- */
function renderKPIs() {
var r = RANGES[currentRange];
document.querySelectorAll(".kpi").forEach(function (card) {
var kpi = card.getAttribute("data-kpi");
var valEl = card.querySelector('[data-field="value"]');
var deltaEl = card.querySelector('[data-field="delta"]');
var sparkEl = card.querySelector('[data-field="spark"]');
valEl.textContent = fmtValue(kpi, r.kpis[kpi]);
var d = r.deltas[kpi];
var up = d >= 0;
deltaEl.classList.toggle("up", up);
deltaEl.classList.toggle("down", !up);
deltaEl.textContent = (up ? "▲ " : "▼ ") + Math.abs(d).toFixed(1) + "%";
// spark uses the per-day series for views/visitors; synthesize for others
var series;
if (kpi === "views") series = r.views;
else if (kpi === "visitors") series = r.visitors;
else series = makeSeries(kpi === "time" ? 5 : 9, Math.min(r.views.length, 12),
kpi === "time" ? r.kpis.time : r.kpis.saves / 30, kpi === "time" ? 18 : 60, up ? 4 : -3);
renderSpark(sparkEl, series, up);
});
}
/* ---------- Main traffic chart (two series, area + line, hover) ---------- */
var chart = document.getElementById("chart");
var tooltip = document.getElementById("tooltip");
var chartW = 720, chartH = 260;
var mL = 44, mR = 16, mT = 16, mB = 28;
var plotW = chartW - mL - mR;
var plotH = chartH - mT - mB;
var chartState = null;
function scaleSeries(views, visitors) {
var all = views.concat(visitors);
var max = Math.max.apply(null, all);
var niceMax = Math.ceil(max / 1000) * 1000;
return niceMax;
}
function seriesToPath(values, max, n, close) {
var step = n > 1 ? plotW / (n - 1) : 0;
var d = "";
var pts = [];
for (var i = 0; i < n; i++) {
var x = mL + i * step;
var y = mT + plotH - (values[i] / max) * plotH;
pts.push([x, y]);
d += (i === 0 ? "M" : " L") + x.toFixed(1) + "," + y.toFixed(1);
}
if (close) {
d += " L" + (mL + (n - 1) * step).toFixed(1) + "," + (mT + plotH).toFixed(1);
d += " L" + mL.toFixed(1) + "," + (mT + plotH).toFixed(1) + " Z";
}
return { d: d, pts: pts };
}
function el(tag, attrs) {
var node = document.createElementNS(SVGNS, tag);
for (var k in attrs) node.setAttribute(k, attrs[k]);
return node;
}
function renderChart() {
var r = RANGES[currentRange];
var views = r.views, visitors = r.visitors;
var n = views.length;
var max = scaleSeries(views, visitors);
chart.innerHTML = "";
// gradient defs
var defs = el("defs", {});
function grad(id, color) {
var g = el("linearGradient", { id: id, x1: "0", y1: "0", x2: "0", y2: "1" });
g.appendChild(el("stop", { offset: "0%", "stop-color": color, "stop-opacity": "0.3" }));
g.appendChild(el("stop", { offset: "100%", "stop-color": color, "stop-opacity": "0" }));
return g;
}
defs.appendChild(grad("gViews", "#d6452b"));
defs.appendChild(grad("gVisitors", "#7c8a6b"));
chart.appendChild(defs);
// gridlines + y labels
var ticks = 4;
for (var t = 0; t <= ticks; t++) {
var yv = (max / ticks) * t;
var y = mT + plotH - (yv / max) * plotH;
chart.appendChild(el("line", {
class: "grid-line", x1: mL, y1: y, x2: mL + plotW, y2: y
}));
var lbl = el("text", { class: "axis-label", x: mL - 8, y: y + 3, "text-anchor": "end" });
lbl.textContent = yv >= 1000 ? (yv / 1000) + "k" : String(Math.round(yv));
chart.appendChild(lbl);
}
// x labels (a few)
var xCount = Math.min(n, 6);
for (var xi = 0; xi < xCount; xi++) {
var idx = Math.round((xi / (xCount - 1)) * (n - 1));
var xx = mL + (n > 1 ? (idx / (n - 1)) * plotW : 0);
var xl = el("text", { class: "axis-label", x: xx, y: chartH - 8, "text-anchor": "middle" });
var daysAgo = n - idx;
xl.textContent = daysAgo <= 1 ? "today" : "−" + (daysAgo - 1) + "d";
chart.appendChild(xl);
}
var vPath = seriesToPath(visitors, max, n, false);
var vArea = seriesToPath(visitors, max, n, true);
var viewPath = seriesToPath(views, max, n, false);
var viewArea = seriesToPath(views, max, n, true);
chart.appendChild(el("path", { class: "area-visitors", d: vArea.d }));
chart.appendChild(el("path", { class: "area-views", d: viewArea.d }));
chart.appendChild(el("path", { class: "line-visitors", d: vPath.d }));
chart.appendChild(el("path", { class: "line-views", d: viewPath.d }));
// hover layer
var hoverLine = el("line", { class: "hover-line", x1: 0, y1: mT, x2: 0, y2: mT + plotH });
hoverLine.style.opacity = "0";
chart.appendChild(hoverLine);
var dotV = el("circle", { class: "hover-dot", r: "4", stroke: "var(--tomato)" });
var dotU = el("circle", { class: "hover-dot", r: "4", stroke: "var(--sage)" });
dotV.style.opacity = "0"; dotU.style.opacity = "0";
chart.appendChild(dotV); chart.appendChild(dotU);
chartState = {
n: n, max: max, views: views, visitors: visitors,
viewPts: viewPath.pts, visitorPts: vPath.pts,
hoverLine: hoverLine, dotV: dotV, dotU: dotU
};
}
function onChartMove(evt) {
if (!chartState) return;
var rect = chart.getBoundingClientRect();
var px = (evt.clientX - rect.left) / rect.width * chartW;
var rel = (px - mL) / plotW;
var idx = Math.round(rel * (chartState.n - 1));
if (idx < 0) idx = 0;
if (idx > chartState.n - 1) idx = chartState.n - 1;
var vp = chartState.viewPts[idx];
var up = chartState.visitorPts[idx];
chartState.hoverLine.setAttribute("x1", vp[0]);
chartState.hoverLine.setAttribute("x2", vp[0]);
chartState.hoverLine.style.opacity = "1";
chartState.dotV.setAttribute("cx", vp[0]); chartState.dotV.setAttribute("cy", vp[1]); chartState.dotV.style.opacity = "1";
chartState.dotU.setAttribute("cx", up[0]); chartState.dotU.setAttribute("cy", up[1]); chartState.dotU.style.opacity = "1";
var daysAgo = chartState.n - idx - 1;
var dateLabel = daysAgo === 0 ? "Today" : daysAgo + " day" + (daysAgo === 1 ? "" : "s") + " ago";
tooltip.hidden = false;
tooltip.innerHTML =
'<span class="tt-date">' + dateLabel + '</span>' +
'<span class="tt-row"><i class="dot dot-views"></i>Views <strong>' + fmtInt(chartState.views[idx]) + '</strong></span>' +
'<span class="tt-row"><i class="dot dot-visitors"></i>Visitors <strong>' + fmtInt(chartState.visitors[idx]) + '</strong></span>';
var leftPx = (vp[0] / chartW) * rect.width;
var topPx = (vp[1] / chartH) * rect.height;
tooltip.style.left = leftPx + "px";
tooltip.style.top = (topPx - 8) + "px";
}
function onChartLeave() {
if (!chartState) return;
chartState.hoverLine.style.opacity = "0";
chartState.dotV.style.opacity = "0";
chartState.dotU.style.opacity = "0";
tooltip.hidden = true;
}
chart.addEventListener("mousemove", onChartMove);
chart.addEventListener("mouseleave", onChartLeave);
/* ---------- Range toggle ---------- */
var rangeBtns = document.querySelectorAll(".range-btn");
var chartSub = document.querySelector('[data-field="chart-sub"]');
rangeBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
var r = parseInt(btn.getAttribute("data-range"), 10);
if (r === currentRange) return;
currentRange = r;
rangeBtns.forEach(function (b) {
var active = b === btn;
b.classList.toggle("is-active", active);
b.setAttribute("aria-pressed", active ? "true" : "false");
});
renderKPIs();
renderChart();
if (chartSub) chartSub.textContent = "Page views & visitors · " + RANGES[currentRange].label;
toast("Stats updated · " + RANGES[currentRange].label);
});
});
/* ---------- Top recipes table ---------- */
var RECIPES = [
{ title: "Charred Tomato & Burrata Tart", cat: "Mains · 45 min", emoji: "🍅", grad: "linear-gradient(135deg,#e8765a,#d6452b)", views: 48210, rating: 4.8, saves: 3120, trend: 18 },
{ title: "Saffron Risotto with Peas", cat: "Mains · 35 min", emoji: "🌾", grad: "linear-gradient(135deg,#f1c45a,#e8a33d)", views: 39870, rating: 4.7, saves: 2740, trend: 9 },
{ title: "Lemon & Sage Roast Chicken", cat: "Mains · 80 min", emoji: "🍋", grad: "linear-gradient(135deg,#f3d97a,#c8a85a)", views: 35640, rating: 4.9, saves: 4010, trend: 24 },
{ title: "Garlic Confit Focaccia", cat: "Bread · 3 hr", emoji: "🧄", grad: "linear-gradient(135deg,#d8c79a,#b89a63)", views: 31290, rating: 4.6, saves: 1980, trend: -4 },
{ title: "Roasted Carrot & Harissa Soup", cat: "Soups · 40 min", emoji: "🥕", grad: "linear-gradient(135deg,#e89a55,#c8612b)", views: 27450, rating: 4.5, saves: 1610, trend: 0 },
{ title: "Herb Garden Green Pasta", cat: "Mains · 25 min", emoji: "🌿", grad: "linear-gradient(135deg,#9bb07a,#7c8a6b)", views: 24180, rating: 4.7, saves: 2230, trend: 12 },
{ title: "Brown Butter Fig Galette", cat: "Dessert · 55 min", emoji: "🥧", grad: "linear-gradient(135deg,#cf8a6a,#a8553a)", views: 21060, rating: 4.8, saves: 2890, trend: 7 }
];
var sortKey = "views";
var sortDir = "desc";
var tbody = document.getElementById("recipes-body");
var headers = document.querySelectorAll("#recipes th[data-sort]");
function starString(rating) {
var full = Math.round(rating);
var s = "";
for (var i = 0; i < 5; i++) s += i < full ? "★" : "☆";
return s;
}
function trendCell(t) {
if (t > 0) return '<span class="trend up">▲ ' + t + '%</span>';
if (t < 0) return '<span class="trend down">▼ ' + Math.abs(t) + '%</span>';
return '<span class="trend flat">— 0%</span>';
}
function renderTable() {
var rows = RECIPES.slice().sort(function (a, b) {
var av = a[sortKey], bv = b[sortKey];
if (sortKey === "title") {
av = a.title.replace(/&/g, "&"); bv = b.title.replace(/&/g, "&");
return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
}
return sortDir === "asc" ? av - bv : bv - av;
});
tbody.innerHTML = rows.map(function (r) {
return '<tr>' +
'<td><div class="recipe-cell">' +
'<div class="recipe-thumb" style="background:' + r.grad + '">' + r.emoji + '</div>' +
'<div><div class="recipe-name">' + r.title + '</div>' +
'<div class="recipe-meta">' + r.cat + '</div></div>' +
'</div></td>' +
'<td class="num">' + fmtInt(r.views) + '</td>' +
'<td class="num"><span class="stars" aria-hidden="true">' + starString(r.rating) + '</span> ' +
'<span class="rating-val">' + r.rating.toFixed(1) + '</span></td>' +
'<td class="num">' + fmtInt(r.saves) + '</td>' +
'<td class="num">' + trendCell(r.trend) + '</td>' +
'</tr>';
}).join("");
headers.forEach(function (h) {
var k = h.getAttribute("data-sort");
h.setAttribute("aria-sort", k === sortKey ? (sortDir === "asc" ? "ascending" : "descending") : "none");
});
}
headers.forEach(function (h) {
h.addEventListener("click", function () {
var k = h.getAttribute("data-sort");
if (k === sortKey) {
sortDir = sortDir === "asc" ? "desc" : "asc";
} else {
sortKey = k;
sortDir = k === "title" ? "asc" : "desc";
}
renderTable();
});
});
/* ---------- Comments feed ---------- */
var COMMENTS = [
{ id: 1, name: "Priya N.", color: "#d6452b", on: "Lemon & Sage Roast Chicken", text: "Made this for Sunday dinner — the sage butter under the skin is genius. Family demolished it!", status: "pending" },
{ id: 2, name: "Marco T.", color: "#7c8a6b", on: "Garlic Confit Focaccia", text: "How long does the confit garlic keep in the fridge? Want to batch it.", status: "pending" },
{ id: 3, name: "Dana R.", color: "#e8a33d", on: "Saffron Risotto with Peas", text: "First time using saffron and wow. Took 40 not 35 min but worth every stir. 🌟", status: "pending" },
{ id: 4, name: "Anon", color: "#8a7f73", on: "Charred Tomato & Burrata Tart", text: "buy followers cheap >> spammy-link-here", status: "pending" }
];
var commentList = document.getElementById("comment-list");
var pendingCountEl = document.getElementById("pending-count");
function initials(name) {
return name.split(/\s+/).map(function (w) { return w[0]; }).join("").slice(0, 2).toUpperCase();
}
function updatePendingCount() {
var n = COMMENTS.filter(function (c) { return c.status === "pending"; }).length;
pendingCountEl.textContent = n + " pending";
var navBadge = document.querySelector(".nav-badge");
if (navBadge) navBadge.textContent = n;
}
function renderComments() {
commentList.innerHTML = COMMENTS.map(function (c) {
var resolved = c.status !== "pending";
var actions;
if (resolved) {
actions = '<span class="comment-status ' + (c.status === "approved" ? "approved" : "rejected") + '">' +
(c.status === "approved" ? "✓ Approved" : "✕ Rejected") + '</span>';
} else {
actions = '<div class="comment-actions">' +
'<button class="btn-mini approve" data-act="approve" data-id="' + c.id + '">Approve</button>' +
'<button class="btn-mini reject" data-act="reject" data-id="' + c.id + '">Reject</button>' +
'</div>';
}
return '<li class="comment' + (resolved ? " resolved" : "") + '">' +
'<span class="avatar" style="background:' + c.color + '">' + initials(c.name) + '</span>' +
'<div class="comment-body">' +
'<div class="comment-top"><span class="comment-name">' + c.name + '</span>' +
'<span class="comment-on">on <em>' + c.on + '</em></span></div>' +
'<p class="comment-text">' + c.text + '</p>' +
actions +
'</div></li>';
}).join("");
updatePendingCount();
}
commentList.addEventListener("click", function (e) {
var btn = e.target.closest("button[data-act]");
if (!btn) return;
var id = parseInt(btn.getAttribute("data-id"), 10);
var act = btn.getAttribute("data-act");
var c = COMMENTS.find(function (x) { return x.id === id; });
if (!c) return;
c.status = act === "approve" ? "approved" : "rejected";
renderComments();
toast(act === "approve" ? "Comment approved 🌿" : "Comment rejected");
});
/* ---------- Init ---------- */
renderKPIs();
renderChart();
renderTable();
renderComments();
if (chartSub) chartSub.textContent = "Page views & visitors · " + RANGES[currentRange].label;
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cookbook — Food Blog Dashboard</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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" role="navigation" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">🍅</span>
<span class="brand-text">Saffron & Sage</span>
</div>
<nav class="nav">
<a href="#" class="nav-item is-active" aria-current="page"><span aria-hidden="true">📊</span> Overview</a>
<a href="#" class="nav-item"><span aria-hidden="true">📖</span> Recipes</a>
<a href="#" class="nav-item"><span aria-hidden="true">💬</span> Comments <span class="nav-badge">4</span></a>
<a href="#" class="nav-item"><span aria-hidden="true">📈</span> Traffic</a>
<a href="#" class="nav-item"><span aria-hidden="true">📨</span> Newsletter</a>
<a href="#" class="nav-item"><span aria-hidden="true">⚙️</span> Settings</a>
</nav>
<div class="sidebar-card">
<div class="sidebar-card-photo" aria-hidden="true">🌿</div>
<p class="sidebar-card-title">Plan: Pro Kitchen</p>
<p class="sidebar-card-sub">Renews Jul 1 · 28k / 50k views</p>
<div class="meter" role="progressbar" aria-valuenow="56" aria-valuemin="0" aria-valuemax="100" aria-label="Monthly view quota">
<span style="width:56%"></span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main" role="main">
<header class="topbar" role="banner">
<div>
<p class="kicker">Dashboard</p>
<h1 class="page-title">Good morning, Marisol</h1>
<p class="page-sub">Here's how the kitchen is cooking this week.</p>
</div>
<div class="range" role="group" aria-label="Date range">
<button class="range-btn" data-range="7" aria-pressed="false">7d</button>
<button class="range-btn is-active" data-range="30" aria-pressed="true">30d</button>
<button class="range-btn" data-range="90" aria-pressed="false">90d</button>
</div>
</header>
<!-- KPI row -->
<section class="kpis" aria-label="Key metrics">
<article class="kpi" data-kpi="views">
<p class="kpi-label">Page views</p>
<p class="kpi-value" data-field="value">—</p>
<div class="kpi-foot">
<span class="delta" data-field="delta">—</span>
<svg class="spark" data-field="spark" viewBox="0 0 100 28" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
<article class="kpi" data-kpi="visitors">
<p class="kpi-label">Unique visitors</p>
<p class="kpi-value" data-field="value">—</p>
<div class="kpi-foot">
<span class="delta" data-field="delta">—</span>
<svg class="spark" data-field="spark" viewBox="0 0 100 28" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
<article class="kpi" data-kpi="time">
<p class="kpi-label">Avg. time on recipe</p>
<p class="kpi-value" data-field="value">—</p>
<div class="kpi-foot">
<span class="delta" data-field="delta">—</span>
<svg class="spark" data-field="spark" viewBox="0 0 100 28" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
<article class="kpi" data-kpi="saves">
<p class="kpi-label">Recipe saves</p>
<p class="kpi-value" data-field="value">—</p>
<div class="kpi-foot">
<span class="delta" data-field="delta">—</span>
<svg class="spark" data-field="spark" viewBox="0 0 100 28" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
</section>
<div class="grid">
<!-- Traffic chart -->
<section class="panel chart-panel" aria-label="Traffic over time">
<div class="panel-head">
<div>
<h2 class="panel-title">Traffic</h2>
<p class="panel-sub" data-field="chart-sub">Page views & visitors</p>
</div>
<div class="legend" aria-hidden="true">
<span class="legend-item"><i class="dot dot-views"></i>Views</span>
<span class="legend-item"><i class="dot dot-visitors"></i>Visitors</span>
</div>
</div>
<div class="chart-wrap">
<svg id="chart" viewBox="0 0 720 260" preserveAspectRatio="none" role="img" aria-label="Line and area chart of traffic over the selected range"></svg>
<div class="chart-tooltip" id="tooltip" role="status" aria-live="polite" hidden></div>
</div>
</section>
<!-- Comments feed -->
<section class="panel comments-panel" aria-label="Recent comments">
<div class="panel-head">
<h2 class="panel-title">Recent comments</h2>
<span class="pill" id="pending-count">4 pending</span>
</div>
<ul class="comment-list" id="comment-list"></ul>
</section>
<!-- Top recipes table -->
<section class="panel table-panel" aria-label="Top recipes">
<div class="panel-head">
<h2 class="panel-title">Top recipes</h2>
<p class="panel-sub">Sort any column · click headers</p>
</div>
<div class="table-scroll">
<table class="recipes" id="recipes">
<thead>
<tr>
<th scope="col" data-sort="title" aria-sort="none">Recipe <span class="caret"></span></th>
<th scope="col" class="num" data-sort="views" aria-sort="descending">Views <span class="caret"></span></th>
<th scope="col" class="num" data-sort="rating" aria-sort="none">Rating <span class="caret"></span></th>
<th scope="col" class="num" data-sort="saves" aria-sort="none">Saves <span class="caret"></span></th>
<th scope="col" class="num" data-sort="trend" aria-sort="none">Trend <span class="caret"></span></th>
</tr>
</thead>
<tbody id="recipes-body"></tbody>
</table>
</div>
</section>
</div>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Food Blog Dashboard (traffic · top recipes)
A self-contained admin view for the fictional Saffron & Sage food blog. A sticky sidebar holds the nav and a plan card; the main area opens with four KPI cards — page views, unique visitors, average time on a recipe, and recipe saves — each pairing a serif headline figure with a coloured delta and a tiny inline-SVG sparkline. The whole surface uses a warm cookbook palette: cream page, paper panels, tomato and saffron accents, and gradient food “photos” rendered entirely in CSS.
The date-range toggle (7d / 30d / 90d) is the spine of the page. Switching ranges recomputes every KPI and its delta and redraws the inline-SVG traffic chart, which layers a tomato views line and a sage visitors line over soft gradient areas, with gridlines, axis labels, and a hover tooltip that reads off the day under the cursor. The top-recipes table sorts by any column — click a header to toggle ascending and descending, with an aria-sort caret following along.
The recent-comments feed is interactive too: each pending comment has Approve and Reject buttons that resolve it in place, update the pending counter and the sidebar badge, and fire a toast. One comment is obvious spam so the moderation flow has something to do. Everything is keyboard-usable with visible focus, the layout collapses to a single column by ~720px, and all numbers are fictional.
Illustrative UI only — recipes & nutrition data are fictional, not dietary advice.