Museum — Collection Catalog Manager
A registrar-facing admin tool for managing a fictional museum's permanent collection. A refined data table lists objects by accession number, title, artist, date, medium, location, and status, with live search, status and medium filters, sortable columns, and pagination. A slide-over form handles add and edit with validation, deletes go through a confirm dialog, and a bulk-select bar reassigns status across many objects at once. Summary stat cards track holdings, all driven client-side over seeded data.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow-sm: 0 1px 2px rgba(28, 27, 25, 0.05), 0 1px 1px rgba(28, 27, 25, 0.04);
--shadow-md: 0 8px 24px rgba(28, 27, 25, 0.1);
--shadow-lg: 0 24px 60px rgba(28, 27, 25, 0.18);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
font-family: var(--sans);
color: var(--ink);
background: var(--paper);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 40px 28px 72px;
}
/* Masthead */
.masthead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding-bottom: 26px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 16px; }
.seal {
color: var(--gold);
flex: none;
}
.kicker {
margin: 0;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.brand-text h1 {
margin: 2px 0 0;
font-family: var(--serif);
font-weight: 600;
font-size: 34px;
line-height: 1.1;
color: var(--charcoal);
letter-spacing: 0.01em;
}
.dept-tag {
display: inline-block;
font-size: 12px;
color: var(--ink-2);
background: var(--wall);
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 14px;
letter-spacing: 0.02em;
}
/* Stats */
.stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin: 26px 0;
}
.stat-card {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
}
.stat-label {
margin: 0;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.stat-value {
margin: 6px 0 0;
font-family: var(--serif);
font-weight: 600;
font-size: 30px;
color: var(--charcoal);
line-height: 1;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.search-wrap {
position: relative;
flex: 1 1 320px;
min-width: 220px;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
pointer-events: none;
}
.search-wrap input {
width: 100%;
padding: 12px 14px 12px 42px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--wall);
font: inherit;
color: var(--ink);
}
.filters { display: flex; gap: 12px; flex-wrap: wrap; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field.inline { flex-direction: row; align-items: center; gap: 8px; }
.field.block { gap: 6px; }
.field > span {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.field i {
color: var(--gold-d);
font-style: normal;
}
.field select,
.field input {
font: inherit;
color: var(--ink);
background: var(--wall);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 10px 12px;
}
.field select { min-width: 158px; cursor: pointer; }
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
/* Buttons */
.btn {
font: inherit;
font-weight: 500;
border-radius: var(--r-sm);
padding: 11px 18px;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease, color 0.15s ease;
white-space: nowrap;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--charcoal);
color: #fff;
}
.btn-primary:hover { background: #000; }
.btn-ghost {
background: var(--gold-50);
color: var(--gold-d);
border-color: rgba(169, 129, 64, 0.4);
}
.btn-ghost:hover { background: #ecdfc6; }
.btn-quiet {
background: var(--wall);
color: var(--ink-2);
border-color: var(--line-2);
}
.btn-quiet:hover { background: #faf8f3; color: var(--ink); }
.btn-quiet:disabled { opacity: 0.45; cursor: default; }
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover { background: #9d3d30; }
/* Bulk bar */
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
background: var(--charcoal);
color: #fff;
border-radius: var(--r-md);
padding: 12px 18px;
margin-bottom: 14px;
}
.bulk-count { font-weight: 600; letter-spacing: 0.02em; }
.bulk-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.bulk-bar .field > span { color: rgba(255, 255, 255, 0.65); }
.bulk-bar select {
background: #2a2825;
color: #fff;
border-color: rgba(255, 255, 255, 0.25);
}
/* Table */
.table-wrap {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
overflow-x: auto;
}
.catalog {
width: 100%;
border-collapse: collapse;
font-size: 14px;
min-width: 880px;
}
.catalog thead th {
text-align: left;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: #fbfaf6;
white-space: nowrap;
}
.catalog .sort {
background: none;
border: none;
font: inherit;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
}
.catalog .sort:hover { color: var(--ink); }
.catalog .sort .arrow::after { content: "⇅"; opacity: 0.5; }
.catalog .sort.asc .arrow::after { content: "↑"; opacity: 1; color: var(--gold-d); }
.catalog .sort.desc .arrow::after { content: "↓"; opacity: 1; color: var(--gold-d); }
.catalog tbody td {
padding: 13px 16px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.catalog tbody tr:last-child td { border-bottom: none; }
.catalog tbody tr { transition: background 0.12s ease; }
.catalog tbody tr:hover { background: #fbfaf6; }
.catalog tbody tr.selected { background: var(--gold-50); }
.col-check, .col-act { width: 1%; }
.col-num { white-space: nowrap; }
.cell-thumb {
display: flex;
align-items: center;
gap: 12px;
}
.thumb {
width: 40px;
height: 40px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
flex: none;
box-shadow: inset 0 0 0 3px #fff;
}
.cell-accession {
font-variant-numeric: tabular-nums;
font-family: "Inter", monospace;
letter-spacing: 0.01em;
color: var(--ink-2);
white-space: nowrap;
}
.cell-title {
font-family: var(--serif);
font-size: 17px;
font-weight: 600;
color: var(--charcoal);
line-height: 1.2;
}
.cell-sub { color: var(--muted); font-size: 12.5px; }
.cell-muted { color: var(--ink-2); }
/* Status badges */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--line-2);
white-space: nowrap;
}
.badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.badge.onview { color: var(--ok); background: rgba(63, 125, 86, 0.1); border-color: rgba(63, 125, 86, 0.3); }
.badge.storage { color: var(--ink-2); background: #f3f1ec; border-color: var(--line-2); }
.badge.loan { color: var(--gold-d); background: var(--gold-50); border-color: rgba(169, 129, 64, 0.35); }
.badge.conservation { color: var(--warn); background: rgba(184, 132, 44, 0.1); border-color: rgba(184, 132, 44, 0.3); }
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
.icon-btn {
background: none;
border: 1px solid transparent;
border-radius: var(--r-sm);
width: 32px;
height: 32px;
cursor: pointer;
color: var(--muted);
font-size: 15px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.icon-btn:hover { background: #f3f1ec; color: var(--ink); border-color: var(--line); }
.icon-btn.danger:hover { background: rgba(180, 73, 58, 0.1); color: var(--danger); border-color: rgba(180, 73, 58, 0.3); }
input[type="checkbox"] {
width: 17px;
height: 17px;
accent-color: var(--gold-d);
cursor: pointer;
}
/* Empty state */
.empty {
text-align: center;
padding: 64px 24px;
}
.empty-title {
font-family: var(--serif);
font-size: 24px;
font-weight: 600;
color: var(--charcoal);
margin: 0 0 6px;
}
.empty-sub { color: var(--muted); margin: 0 0 18px; }
/* Pager */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 20px;
flex-wrap: wrap;
}
.pager-info { color: var(--muted); font-size: 13px; margin: 0; }
.pager-controls { display: flex; align-items: center; gap: 8px; }
.pager-pages {
display: inline-flex;
gap: 4px;
}
.page-num {
min-width: 34px;
height: 34px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink-2);
font: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.page-num:hover { background: #faf8f3; }
.page-num.active {
background: var(--charcoal);
color: #fff;
border-color: var(--charcoal);
}
/* Slide-over */
.scrim {
position: fixed;
inset: 0;
background: rgba(28, 27, 25, 0.42);
z-index: 40;
animation: fade 0.18s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.slideover {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: min(460px, 100%);
background: var(--paper);
border-left: 1px solid var(--line);
box-shadow: var(--shadow-lg);
z-index: 50;
display: flex;
flex-direction: column;
animation: slidein 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes slidein { from { transform: translateX(100%); } to { transform: translateX(0); } }
.slide-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 26px;
border-bottom: 1px solid var(--line);
}
.slide-head h2 {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: 26px;
color: var(--charcoal);
}
#object-form {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
padding: 24px 26px;
overflow-y: auto;
flex: 1;
}
.form-grid .field.block:nth-child(1),
.form-grid .field.block:nth-child(2) { grid-column: 1 / -1; }
.form-grid input,
.form-grid select { width: 100%; }
.err {
font-style: normal;
font-size: 12px;
color: var(--danger);
min-height: 0;
display: none;
}
.field.invalid .err { display: block; }
.field.invalid input { border-color: var(--danger); }
.slide-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 18px 26px;
border-top: 1px solid var(--line);
background: var(--wall);
}
/* Confirm */
.confirm {
position: fixed;
z-index: 60;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(400px, calc(100% - 40px));
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
padding: 28px;
animation: pop 0.18s ease;
}
@keyframes pop { from { opacity: 0; transform: translate(-50%, -46%); } to { opacity: 1; transform: translate(-50%, -50%); } }
.confirm h2 {
margin: 0 0 8px;
font-family: var(--serif);
font-weight: 600;
font-size: 24px;
color: var(--charcoal);
}
.confirm p { margin: 0 0 22px; color: var(--ink-2); }
.confirm-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* Toast */
.toast-host {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 80;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.toast {
background: var(--charcoal);
color: #fff;
padding: 12px 20px;
border-radius: var(--r-sm);
box-shadow: var(--shadow-md);
font-size: 14px;
animation: toastin 0.22s ease;
}
.toast .t-gold { color: var(--gold); }
@keyframes toastin { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
[hidden] { display: none !important; }
@media (max-width: 860px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 520px) {
.app { padding: 26px 16px 56px; }
.brand-text h1 { font-size: 26px; }
.stats { grid-template-columns: 1fr 1fr; gap: 10px; }
.stat-value { font-size: 24px; }
.toolbar { flex-direction: column; align-items: stretch; }
.filters { width: 100%; }
.field select { flex: 1; min-width: 0; }
.add-btn, #add-btn { width: 100%; }
.form-grid { grid-template-columns: 1fr; }
.form-grid .field.block { grid-column: 1 / -1; }
.masthead-actions { display: none; }
.pager { justify-content: center; }
}(function () {
"use strict";
// ---- Seed data ---------------------------------------------------------
var PALETTES = [
["#b08968", "#7f5539"], ["#5e6472", "#2b2d34"], ["#a3b18a", "#588157"],
["#cdb4db", "#8e6c9b"], ["#c9ada7", "#9a8c98"], ["#83c5be", "#006d77"],
["#e9c46a", "#bb8a2e"], ["#dda15e", "#bc6c25"], ["#9aa0a8", "#6b717a"],
["#a0937d", "#6e5e4e"], ["#b5838d", "#6d4c52"], ["#cbc0d3", "#8a7fa3"]
];
var STATUSES = ["On view", "In storage", "On loan", "In conservation"];
var seed = [
{ accession: "1962.014.2", title: "Harbor at Dawn", artist: "Élise Renaud", date: "1908", medium: "Oil on canvas", location: "Gallery 14 · West Wing", status: "On view" },
{ accession: "1971.203.1", title: "Study of Folded Cloth", artist: "Anton Veerhoff", date: "c. 1640", medium: "Red chalk on laid paper", location: "Works on Paper · Storage", status: "In storage" },
{ accession: "1955.087.5", title: "The Cartographer's Table", artist: "Mira Solberg", date: "1931", medium: "Tempera on panel", location: "Gallery 7 · North Wing", status: "On view" },
{ accession: "2003.118.3", title: "Untitled (Ochre Field)", artist: "Davíd Okonkwo", date: "1974", medium: "Acrylic on linen", location: "Loan · Tate Modern", status: "On loan" },
{ accession: "1948.056.9", title: "Reliquary Casket", artist: "Workshop of Limoges", date: "c. 1220", medium: "Gilt copper, champlevé enamel", location: "Medieval · Vitrine 3", status: "On view" },
{ accession: "1989.041.7", title: "Two Figures, Descending", artist: "Hélène Marchetti", date: "1956", medium: "Bronze with brown patina", location: "Conservation Lab", status: "In conservation" },
{ accession: "1933.012.4", title: "Still Life with Quince", artist: "Joaquín Beltrán", date: "1899", medium: "Oil on canvas", location: "Gallery 11 · West Wing", status: "On view" },
{ accession: "2011.230.1", title: "Circuit (Variation IX)", artist: "Yuki Tanabe", date: "2009", medium: "Inkjet print on rag", location: "Photography · Storage", status: "In storage" },
{ accession: "1967.099.2", title: "Veiled Portrait of a Lady", artist: "Unknown (Florentine)", date: "c. 1490", medium: "Tempera and gold on panel", location: "Gallery 3 · East Wing", status: "On view" },
{ accession: "1998.144.6", title: "Salt Marsh, Evening", artist: "Greta Lindqvist", date: "1962", medium: "Watercolor on paper", location: "Loan · Stedelijk", status: "On loan" },
{ accession: "1925.007.8", title: "Amphora with Dancers", artist: "Attributed to the Berlin Painter", date: "c. 480 BCE", medium: "Terracotta, red-figure", location: "Antiquities · Vitrine 1", status: "On view" },
{ accession: "2007.166.3", title: "Glasshouse No. 4", artist: "Pieter Vanderveld", date: "2001", medium: "Chromogenic print", location: "Photography · Storage", status: "In storage" },
{ accession: "1972.058.1", title: "Composition in Slate", artist: "Nadia Brening", date: "1948", medium: "Oil and sand on board", location: "Gallery 16 · West Wing", status: "On view" },
{ accession: "1944.033.5", title: "Embroidered Court Robe", artist: "Unknown (Qing dynasty)", date: "18th c.", medium: "Silk, gold-wrapped thread", location: "Textiles · Cold Storage", status: "In storage" },
{ accession: "2015.201.2", title: "Threshold (After Rain)", artist: "Imani Cole", date: "2014", medium: "Mixed media on panel", location: "Gallery 22 · Contemporary", status: "On view" },
{ accession: "1960.077.4", title: "The Astronomer's Globe", artist: "Workshop of Coronelli", date: "c. 1688", medium: "Engraved paper over plaster", location: "Conservation Lab", status: "In conservation" },
{ accession: "1981.109.6", title: "Red Interior with Chair", artist: "Lucien Abadie", date: "1953", medium: "Gouache on board", location: "Gallery 18 · West Wing", status: "On view" },
{ accession: "1937.024.3", title: "Coastal Cliffs, Brittany", artist: "Marthe Caillou", date: "1911", medium: "Oil on canvas", location: "Loan · Musée d'Orsay", status: "On loan" },
{ accession: "2019.245.1", title: "Signal / Noise", artist: "Rashid Iqbal", date: "2018", medium: "LED, microcontroller, steel", location: "Gallery 24 · New Media", status: "On view" },
{ accession: "1953.061.7", title: "Portrait of a Collector", artist: "Wilhelm Brandt", date: "1842", medium: "Oil on canvas", location: "Gallery 5 · East Wing", status: "On view" },
{ accession: "1929.018.2", title: "Funerary Stele", artist: "Unknown (Attic)", date: "c. 350 BCE", medium: "Pentelic marble", location: "Antiquities · Storage", status: "In storage" },
{ accession: "2009.188.4", title: "Drift (Triptych)", artist: "Sonia Vidal", date: "2006", medium: "Oil on three panels", location: "Conservation Lab", status: "In conservation" },
{ accession: "1976.092.1", title: "Night Window", artist: "Theo Lindemann", date: "1959", medium: "Etching and aquatint", location: "Works on Paper · Storage", status: "In storage" },
{ accession: "1991.137.5", title: "The Weavers", artist: "Concha Ferrer", date: "1968", medium: "Wool tapestry", location: "Gallery 9 · North Wing", status: "On view" }
];
var objects = seed.map(function (o, i) {
return Object.assign({ id: "obj-" + i, pal: PALETTES[i % PALETTES.length] }, o);
});
// ---- State -------------------------------------------------------------
var state = {
search: "",
status: "",
medium: "",
sortKey: "accession",
sortDir: "asc",
page: 1,
perPage: 8,
selected: new Set()
};
// ---- Elements ----------------------------------------------------------
var $ = function (id) { return document.getElementById(id); };
var rowsEl = $("rows");
var searchEl = $("search");
var statusFilterEl = $("filter-status");
var mediumFilterEl = $("filter-medium");
var selectAllEl = $("select-all");
// ---- Toast -------------------------------------------------------------
function toast(msg) {
var host = $("toast-host");
var el = document.createElement("div");
el.className = "toast";
el.innerHTML = msg;
host.appendChild(el);
setTimeout(function () {
el.style.transition = "opacity 0.3s ease";
el.style.opacity = "0";
setTimeout(function () { el.remove(); }, 320);
}, 2400);
}
// ---- Helpers -----------------------------------------------------------
function statusClass(s) {
return { "On view": "onview", "In storage": "storage", "On loan": "loan", "In conservation": "conservation" }[s] || "storage";
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
// Sort key for accession & date that handles BCE / century strings gracefully.
function dateRank(d) {
var m = d.match(/(\d{3,4})\s*BCE/i);
if (m) return -parseInt(m[1], 10);
m = d.match(/(\d{1,2})(st|nd|rd|th)\s*c/i);
if (m) return (parseInt(m[1], 10) - 1) * 100 + 50;
m = d.match(/(\d{3,4})/);
if (m) return parseInt(m[1], 10);
return 0;
}
function compare(a, b) {
var k = state.sortKey, dir = state.sortDir === "asc" ? 1 : -1;
var av, bv;
if (k === "date") { av = dateRank(a.date); bv = dateRank(b.date); }
else { av = (a[k] || "").toLowerCase(); bv = (b[k] || "").toLowerCase(); }
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
}
function filtered() {
var q = state.search.trim().toLowerCase();
return objects.filter(function (o) {
if (state.status && o.status !== state.status) return false;
if (state.medium && o.medium !== state.medium) return false;
if (q) {
var hay = (o.title + " " + o.artist + " " + o.accession + " " + o.medium + " " + o.location + " " + o.date).toLowerCase();
if (hay.indexOf(q) === -1) return false;
}
return true;
}).sort(compare);
}
// ---- Stats -------------------------------------------------------------
function renderStats() {
$("stat-total").textContent = objects.length;
var c = { "On view": 0, "In storage": 0, "On loan": 0, "In conservation": 0 };
objects.forEach(function (o) { c[o.status]++; });
$("stat-onview").textContent = c["On view"];
$("stat-storage").textContent = c["In storage"];
$("stat-loan").textContent = c["On loan"];
$("stat-conservation").textContent = c["In conservation"];
}
// ---- Medium filter / datalist ------------------------------------------
function renderMediumOptions() {
var media = Array.from(new Set(objects.map(function (o) { return o.medium; }))).sort();
var keep = state.medium;
mediumFilterEl.innerHTML = '<option value="">All media</option>' +
media.map(function (m) {
return '<option value="' + escapeHtml(m) + '"' + (m === keep ? " selected" : "") + ">" + escapeHtml(m) + "</option>";
}).join("");
var dl = $("medium-list");
if (dl) dl.innerHTML = media.map(function (m) { return '<option value="' + escapeHtml(m) + '">'; }).join("");
}
// ---- Render table ------------------------------------------------------
function render() {
var list = filtered();
var total = list.length;
var pages = Math.max(1, Math.ceil(total / state.perPage));
if (state.page > pages) state.page = pages;
var start = (state.page - 1) * state.perPage;
var pageItems = list.slice(start, start + state.perPage);
rowsEl.innerHTML = pageItems.map(function (o) {
var sel = state.selected.has(o.id);
var grad = "linear-gradient(135deg," + o.pal[0] + "," + o.pal[1] + ")";
return '<tr class="' + (sel ? "selected" : "") + '" data-id="' + o.id + '">' +
'<td class="col-check"><input type="checkbox" class="row-check" ' + (sel ? "checked" : "") + ' aria-label="Select ' + escapeHtml(o.title) + '" /></td>' +
'<td class="cell-accession">' + escapeHtml(o.accession) + "</td>" +
'<td><div class="cell-thumb"><span class="thumb" style="background:' + grad + '" aria-hidden="true"></span>' +
'<div><div class="cell-title">' + escapeHtml(o.title) + "</div></div></div></td>" +
'<td class="cell-muted">' + escapeHtml(o.artist) + "</td>" +
'<td class="col-num cell-muted">' + escapeHtml(o.date) + "</td>" +
'<td class="cell-sub">' + escapeHtml(o.medium) + "</td>" +
'<td class="cell-sub">' + escapeHtml(o.location) + "</td>" +
'<td><span class="badge ' + statusClass(o.status) + '">' + escapeHtml(o.status) + "</span></td>" +
'<td class="col-act"><div class="row-actions">' +
'<button class="icon-btn" data-act="edit" aria-label="Edit ' + escapeHtml(o.title) + '">✎</button>' +
'<button class="icon-btn danger" data-act="delete" aria-label="Remove ' + escapeHtml(o.title) + '">🗑</button>' +
"</div></td></tr>";
}).join("");
$("empty").hidden = total !== 0;
document.querySelector(".catalog").style.display = total === 0 ? "none" : "";
// Pager
var shown = total === 0 ? 0 : start + 1;
var shownEnd = Math.min(start + state.perPage, total);
$("pager-info").textContent = total === 0 ? "No objects" : "Showing " + shown + "–" + shownEnd + " of " + total;
$("prev").disabled = state.page <= 1;
$("next").disabled = state.page >= pages;
var pagesEl = $("pager-pages");
pagesEl.innerHTML = "";
for (var p = 1; p <= pages; p++) {
var b = document.createElement("button");
b.className = "page-num" + (p === state.page ? " active" : "");
b.textContent = p;
b.dataset.page = p;
pagesEl.appendChild(b);
}
// select-all reflects current page
var allSelected = pageItems.length > 0 && pageItems.every(function (o) { return state.selected.has(o.id); });
selectAllEl.checked = allSelected;
selectAllEl.indeterminate = !allSelected && pageItems.some(function (o) { return state.selected.has(o.id); });
renderBulkBar();
renderSortHeaders();
}
function renderSortHeaders() {
document.querySelectorAll(".catalog .sort").forEach(function (btn) {
btn.classList.remove("asc", "desc");
if (btn.dataset.key === state.sortKey) btn.classList.add(state.sortDir);
});
}
function renderBulkBar() {
var n = state.selected.size;
var bar = $("bulk-bar");
bar.hidden = n === 0;
$("bulk-count").textContent = n + " selected";
}
// ---- Sorting -----------------------------------------------------------
document.querySelectorAll(".catalog .sort").forEach(function (btn) {
btn.addEventListener("click", function () {
var key = btn.dataset.key;
if (state.sortKey === key) {
state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
} else {
state.sortKey = key;
state.sortDir = "asc";
}
render();
});
});
// ---- Filters / search --------------------------------------------------
searchEl.addEventListener("input", function () { state.search = searchEl.value; state.page = 1; render(); });
statusFilterEl.addEventListener("change", function () { state.status = statusFilterEl.value; state.page = 1; render(); });
mediumFilterEl.addEventListener("change", function () { state.medium = mediumFilterEl.value; state.page = 1; render(); });
$("empty-reset").addEventListener("click", function () {
state.search = ""; state.status = ""; state.medium = ""; state.page = 1;
searchEl.value = ""; statusFilterEl.value = ""; mediumFilterEl.value = "";
render();
toast("Filters reset.");
});
// ---- Pager -------------------------------------------------------------
$("prev").addEventListener("click", function () { if (state.page > 1) { state.page--; render(); } });
$("next").addEventListener("click", function () { state.page++; render(); });
$("pager-pages").addEventListener("click", function (e) {
var b = e.target.closest(".page-num");
if (b) { state.page = parseInt(b.dataset.page, 10); render(); }
});
// ---- Row interactions --------------------------------------------------
rowsEl.addEventListener("change", function (e) {
var cb = e.target.closest(".row-check");
if (!cb) return;
var id = cb.closest("tr").dataset.id;
if (cb.checked) state.selected.add(id); else state.selected.delete(id);
render();
});
rowsEl.addEventListener("click", function (e) {
var btn = e.target.closest("[data-act]");
if (!btn) return;
var id = btn.closest("tr").dataset.id;
if (btn.dataset.act === "edit") openForm(id);
else if (btn.dataset.act === "delete") askDelete([id]);
});
selectAllEl.addEventListener("change", function () {
var pageItems = currentPageItems();
if (selectAllEl.checked) pageItems.forEach(function (o) { state.selected.add(o.id); });
else pageItems.forEach(function (o) { state.selected.delete(o.id); });
render();
});
function currentPageItems() {
var list = filtered();
var start = (state.page - 1) * state.perPage;
return list.slice(start, start + state.perPage);
}
// ---- Bulk actions ------------------------------------------------------
$("bulk-apply").addEventListener("click", function () {
var s = $("bulk-status").value;
if (!s) { toast("Choose a status to apply."); return; }
var n = 0;
objects.forEach(function (o) { if (state.selected.has(o.id)) { o.status = s; n++; } });
$("bulk-status").value = "";
renderStats();
render();
toast("Set <span class=\"t-gold\">" + n + "</span> object" + (n === 1 ? "" : "s") + " to " + escapeHtml(s) + ".");
});
$("bulk-clear").addEventListener("click", function () {
state.selected.clear();
render();
});
// ---- Slide-over form ---------------------------------------------------
var slideover = $("slideover");
var scrim = $("scrim");
var form = $("object-form");
var editingId = null;
var lastFocus = null;
function openForm(id) {
editingId = id || null;
lastFocus = document.activeElement;
form.reset();
clearErrors();
renderMediumOptions();
$("slide-title").textContent = id ? "Edit object" : "New object";
if (id) {
var o = objects.find(function (x) { return x.id === id; });
$("f-accession").value = o.accession;
$("f-title").value = o.title;
$("f-artist").value = o.artist;
$("f-date").value = o.date;
$("f-medium").value = o.medium;
$("f-location").value = o.location;
$("f-status").value = o.status;
}
scrim.hidden = false;
slideover.hidden = false;
setTimeout(function () { $("f-accession").focus(); }, 30);
document.addEventListener("keydown", onKey);
}
function closeForm() {
slideover.hidden = true;
scrim.hidden = true;
document.removeEventListener("keydown", onKey);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function onKey(e) { if (e.key === "Escape") closeForm(); }
$("slide-close").addEventListener("click", closeForm);
$("slide-cancel").addEventListener("click", closeForm);
scrim.addEventListener("click", closeForm);
function clearErrors() {
form.querySelectorAll(".field.invalid").forEach(function (f) { f.classList.remove("invalid"); });
form.querySelectorAll(".err").forEach(function (e) { e.textContent = ""; });
}
function setError(name, msg) {
var input = form.querySelector('[name="' + name + '"]');
var field = input.closest(".field");
field.classList.add("invalid");
var err = field.querySelector('.err[data-for="' + name + '"]');
if (err) err.textContent = msg;
}
form.addEventListener("submit", function (e) {
e.preventDefault();
clearErrors();
var data = {
accession: $("f-accession").value.trim(),
title: $("f-title").value.trim(),
artist: $("f-artist").value.trim() || "Unknown",
date: $("f-date").value.trim(),
medium: $("f-medium").value.trim() || "—",
location: $("f-location").value.trim() || "Unassigned",
status: $("f-status").value
};
var ok = true;
if (!data.accession) { setError("accession", "Accession number is required."); ok = false; }
else if (!/^\d{4}\.\d{1,4}(\.\d{1,3})?$/.test(data.accession)) { setError("accession", "Use a format like 2024.118.3."); ok = false; }
else {
var dup = objects.some(function (o) { return o.accession === data.accession && o.id !== editingId; });
if (dup) { setError("accession", "That accession number already exists."); ok = false; }
}
if (!data.title) { setError("title", "Title is required."); ok = false; }
if (!data.date) { setError("date", "A date is required."); ok = false; }
if (!ok) {
form.querySelector(".field.invalid input").focus();
return;
}
if (editingId) {
var o = objects.find(function (x) { return x.id === editingId; });
Object.assign(o, data);
toast("Updated <span class=\"t-gold\">" + escapeHtml(data.title) + "</span>.");
} else {
objects.unshift(Object.assign({
id: "obj-" + Date.now(),
pal: PALETTES[objects.length % PALETTES.length]
}, data));
toast("Catalogued <span class=\"t-gold\">" + escapeHtml(data.title) + "</span>.");
}
closeForm();
renderStats();
renderMediumOptions();
render();
});
$("add-btn").addEventListener("click", function () { openForm(null); });
// ---- Delete confirm ----------------------------------------------------
var confirmEl = $("confirm");
var confirmScrim = $("confirm-scrim");
var pendingDelete = [];
function askDelete(ids) {
pendingDelete = ids;
var body = $("confirm-body");
if (ids.length === 1) {
var o = objects.find(function (x) { return x.id === ids[0]; });
$("confirm-title").textContent = "Remove this object?";
body.innerHTML = "“" + escapeHtml(o.title) + "” (" + escapeHtml(o.accession) + ") will be permanently removed from the catalog.";
} else {
$("confirm-title").textContent = "Remove " + ids.length + " objects?";
body.textContent = "The selected records will be permanently removed from the catalog.";
}
confirmScrim.hidden = false;
confirmEl.hidden = false;
setTimeout(function () { $("confirm-cancel").focus(); }, 30);
document.addEventListener("keydown", onConfirmKey);
}
function closeConfirm() {
confirmEl.hidden = true;
confirmScrim.hidden = true;
pendingDelete = [];
document.removeEventListener("keydown", onConfirmKey);
}
function onConfirmKey(e) { if (e.key === "Escape") closeConfirm(); }
$("confirm-cancel").addEventListener("click", closeConfirm);
confirmScrim.addEventListener("click", closeConfirm);
$("confirm-ok").addEventListener("click", function () {
var ids = pendingDelete.slice();
objects = objects.filter(function (o) { return ids.indexOf(o.id) === -1; });
ids.forEach(function (id) { state.selected.delete(id); });
closeConfirm();
renderStats();
renderMediumOptions();
render();
toast("Removed " + ids.length + " object" + (ids.length === 1 ? "" : "s") + ".");
});
// ---- Init --------------------------------------------------------------
renderStats();
renderMediumOptions();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Museum — Collection Catalog Manager</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&display=swap"
rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="masthead">
<div class="brand">
<div class="seal" aria-hidden="true">
<svg viewBox="0 0 40 40" width="40" height="40">
<rect x="2" y="2" width="36" height="36" rx="6" fill="none" stroke="currentColor" stroke-width="1.4" />
<path d="M8 30 L20 10 L32 30 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
<circle cx="20" cy="24" r="2.4" fill="currentColor" />
</svg>
</div>
<div class="brand-text">
<p class="kicker">Halford Museum of Art</p>
<h1>Collection Catalog Manager</h1>
</div>
</div>
<div class="masthead-actions">
<span class="dept-tag">Registrar · Permanent Collection</span>
</div>
</header>
<section class="stats" aria-label="Catalog summary">
<div class="stat-card">
<p class="stat-label">Total objects</p>
<p class="stat-value" id="stat-total">0</p>
</div>
<div class="stat-card">
<p class="stat-label">On view</p>
<p class="stat-value" id="stat-onview">0</p>
</div>
<div class="stat-card">
<p class="stat-label">In storage</p>
<p class="stat-value" id="stat-storage">0</p>
</div>
<div class="stat-card">
<p class="stat-label">On loan</p>
<p class="stat-value" id="stat-loan">0</p>
</div>
<div class="stat-card">
<p class="stat-label">In conservation</p>
<p class="stat-value" id="stat-conservation">0</p>
</div>
</section>
<section class="toolbar" aria-label="Catalog controls">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="1.6" />
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
<input type="search" id="search" placeholder="Search title, artist, accession no., medium…"
aria-label="Search catalog" autocomplete="off" />
</div>
<div class="filters">
<label class="field">
<span>Status</span>
<select id="filter-status" aria-label="Filter by status">
<option value="">All statuses</option>
<option value="On view">On view</option>
<option value="In storage">In storage</option>
<option value="On loan">On loan</option>
<option value="In conservation">In conservation</option>
</select>
</label>
<label class="field">
<span>Medium</span>
<select id="filter-medium" aria-label="Filter by medium">
<option value="">All media</option>
</select>
</label>
</div>
<button class="btn btn-primary" id="add-btn" type="button">
<span aria-hidden="true">+</span> New object
</button>
</section>
<div class="bulk-bar" id="bulk-bar" hidden>
<span class="bulk-count" id="bulk-count">0 selected</span>
<div class="bulk-actions">
<label class="field inline">
<span>Set status</span>
<select id="bulk-status" aria-label="Bulk set status">
<option value="">Choose…</option>
<option value="On view">On view</option>
<option value="In storage">In storage</option>
<option value="On loan">On loan</option>
<option value="In conservation">In conservation</option>
</select>
</label>
<button class="btn btn-ghost" id="bulk-apply" type="button">Apply</button>
<button class="btn btn-quiet" id="bulk-clear" type="button">Clear selection</button>
</div>
</div>
<section class="table-wrap" aria-label="Catalog objects">
<table class="catalog">
<thead>
<tr>
<th class="col-check">
<input type="checkbox" id="select-all" aria-label="Select all visible objects" />
</th>
<th><button class="sort" data-key="accession" type="button">Accession <span class="arrow"></span></button></th>
<th><button class="sort" data-key="title" type="button">Title <span class="arrow"></span></button></th>
<th><button class="sort" data-key="artist" type="button">Artist <span class="arrow"></span></button></th>
<th class="col-num"><button class="sort" data-key="date" type="button">Date <span class="arrow"></span></button></th>
<th>Medium</th>
<th>Location</th>
<th><button class="sort" data-key="status" type="button">Status <span class="arrow"></span></button></th>
<th class="col-act" aria-label="Actions"></th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" hidden>
<p class="empty-title">No objects match</p>
<p class="empty-sub">Adjust your search or filters to widen the results.</p>
<button class="btn btn-ghost" id="empty-reset" type="button">Reset filters</button>
</div>
</section>
<footer class="pager" aria-label="Pagination">
<p class="pager-info" id="pager-info">Showing 0 of 0</p>
<div class="pager-controls">
<button class="btn btn-quiet" id="prev" type="button">Previous</button>
<span class="pager-pages" id="pager-pages"></span>
<button class="btn btn-quiet" id="next" type="button">Next</button>
</div>
</footer>
</div>
<!-- Slide-over form -->
<div class="scrim" id="scrim" hidden></div>
<aside class="slideover" id="slideover" role="dialog" aria-modal="true" aria-labelledby="slide-title" hidden>
<header class="slide-head">
<h2 id="slide-title">New object</h2>
<button class="icon-btn" id="slide-close" type="button" aria-label="Close form">✕</button>
</header>
<form id="object-form" novalidate>
<div class="form-grid">
<label class="field block">
<span>Accession no. <i>*</i></span>
<input name="accession" id="f-accession" placeholder="2024.118.3" required />
<em class="err" data-for="accession"></em>
</label>
<label class="field block">
<span>Title <i>*</i></span>
<input name="title" id="f-title" placeholder="Untitled (Study in Ochre)" required />
<em class="err" data-for="title"></em>
</label>
<label class="field block">
<span>Artist</span>
<input name="artist" id="f-artist" placeholder="Unknown / Maker name" />
<em class="err" data-for="artist"></em>
</label>
<label class="field block">
<span>Date <i>*</i></span>
<input name="date" id="f-date" placeholder="c. 1921" required />
<em class="err" data-for="date"></em>
</label>
<label class="field block">
<span>Medium</span>
<input name="medium" id="f-medium" placeholder="Oil on canvas" list="medium-list" />
<em class="err" data-for="medium"></em>
</label>
<label class="field block">
<span>Location</span>
<input name="location" id="f-location" placeholder="Gallery 14 · West Wing" />
<em class="err" data-for="location"></em>
</label>
<label class="field block">
<span>Status</span>
<select name="status" id="f-status">
<option value="On view">On view</option>
<option value="In storage">In storage</option>
<option value="On loan">On loan</option>
<option value="In conservation">In conservation</option>
</select>
</label>
</div>
<datalist id="medium-list"></datalist>
<div class="slide-foot">
<button class="btn btn-quiet" type="button" id="slide-cancel">Cancel</button>
<button class="btn btn-primary" type="submit" id="slide-save">Save object</button>
</div>
</form>
</aside>
<!-- Confirm delete -->
<div class="scrim" id="confirm-scrim" hidden></div>
<div class="confirm" id="confirm" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" hidden>
<h2 id="confirm-title">Remove object?</h2>
<p id="confirm-body">This will permanently remove the record from the catalog.</p>
<div class="confirm-actions">
<button class="btn btn-quiet" id="confirm-cancel" type="button">Keep</button>
<button class="btn btn-danger" id="confirm-ok" type="button">Remove</button>
</div>
</div>
<div class="toast-host" id="toast-host" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Collection Catalog Manager
A back-of-house catalog tool styled like a calm curatorial workspace. A row of summary stat cards reports total objects and how many are on view, in storage, on loan, or in conservation. Below, a data table presents each object with a matted color-block thumbnail, accession number in tabular figures, a serif title, and tombstone fields — artist, date, medium, and location — alongside a color-coded status badge. Live search spans every field, while status and medium selects narrow the list, and any column header sorts ascending or descending.
The toolbar’s New object button opens a slide-over form that doubles as the edit panel. It validates the accession number format, flags duplicates, and requires a title and date before saving. Each row offers inline edit and delete actions; deletes route through a confirm dialog that names the object. Selecting rows reveals a bulk action bar that reassigns status across the whole selection in one step. Pagination keeps the table to eight rows at a time with numbered page controls and a running result count.
Everything runs in vanilla JavaScript over a seeded array of two dozen fictional works, performing full client-side create, read, update, and delete with no backend. Modals trap focus, close on Escape or backdrop click, and restore focus to their trigger, and a small toast helper confirms each action.
Illustrative UI only — demo data; not a real museum system.