Add movie collection API and frontend (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-09-21 06:52:11 +00:00
parent 2885727eb2
commit c45878bd86
6 changed files with 2152 additions and 0 deletions
+872
View File
@@ -0,0 +1,872 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Movie Collection · Movie API</title>
<style>
/* ---------------------------------------------------------------------
Design tokens, mirroring the it3206-minisys / meal-api look:
neutral-900 page, white text, white/10 hairlines, white/5 fills.
--------------------------------------------------------------------- */
:root {
--bg: #171717; /* neutral-900 */
--panel: rgba(255, 255, 255, 0.05); /* white/5 */
--panel-hover: rgba(255, 255, 255, 0.05);
--line: rgba(255, 255, 255, 0.1); /* white/10 */
--line-strong: rgba(255, 255, 255, 0.15);
--on: #ffffff;
--muted: rgba(255, 255, 255, 0.5); /* white/50 */
--muted-2: rgba(255, 255, 255, 0.4); /* white/40 */
--muted-3: rgba(255, 255, 255, 0.3); /* white/30 */
--danger: #f87171; /* red-400 */
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100svh;
padding: 6rem 1rem 2rem; /* clears the fixed navbar (pt-24) */
display: flex;
flex-direction: column;
align-items: center;
background: var(--bg);
color: var(--on);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ---- navbar ---------------------------------------------------------- */
.nav {
position: fixed;
left: 0;
top: 0;
z-index: 50;
margin-top: 0.5rem;
width: 100%;
display: flex;
justify-content: center;
padding: 0 0.75rem;
}
.nav-inner {
width: 100%;
max-width: 48rem; /* max-w-3xl */
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: rgba(23, 23, 23, 0.8);
backdrop-filter: blur(8px);
}
.brand {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
text-decoration: none;
color: var(--on);
}
.nav-links {
display: flex;
gap: 0.25rem;
}
.nav-link {
padding: 0.25rem 0.625rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color 0.15s, background-color 0.15s;
}
.nav-link:hover {
color: var(--on);
}
.nav-link.is-active {
background: rgba(255, 255, 255, 0.1);
color: var(--on);
}
/* ---- layout ---------------------------------------------------------- */
main {
width: 100%;
max-width: 48rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.page-head.is-centered {
align-items: center;
text-align: center;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.025em;
}
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--muted);
}
.subtle {
font-size: 0.75rem;
color: var(--muted-3);
}
.section-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
/* ---- controls -------------------------------------------------------- */
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(23, 23, 23, 0.6);
color: var(--on);
font: inherit;
font-size: 0.875rem;
outline: none;
transition: border-color 0.15s;
}
.input::placeholder {
color: var(--muted-3);
}
.input:focus {
border-color: var(--muted-2);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-lg);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
.btn-primary {
background: var(--on);
color: #171717;
font-weight: 600;
}
.btn-primary:hover {
background: rgba(255, 255, 255, 0.9);
}
.btn-ghost {
border: 1px solid var(--line-strong);
background: transparent;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.btn-ghost:hover {
border-color: rgba(255, 255, 255, 0.3);
color: var(--on);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---- panels, fields, errors ------------------------------------------ */
.panel {
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--panel);
}
.panel-head {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-bottom: 1rem;
}
/* The photo's one-line form: title · genre · year · button. */
.form-row {
display: grid;
gap: 0.75rem;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.form-row {
grid-template-columns: 1.6fr 1.15fr 0.7fr auto;
align-items: start;
}
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
min-width: 0;
}
.label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.error {
margin: 0;
font-size: 0.75rem;
color: var(--danger);
}
/* ---- notice ---------------------------------------------------------- */
.notice {
margin: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
}
.notice.is-error {
border-color: rgba(248, 113, 113, 0.35);
background: rgba(248, 113, 113, 0.1);
color: var(--danger);
}
/* ---- table ----------------------------------------------------------- */
.table-wrap {
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--panel);
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
border-bottom: 1px solid var(--line);
background: var(--panel);
}
th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-2); /* white/40 */
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.15s;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--panel-hover);
}
tbody tr.is-selected {
background: rgba(255, 255, 255, 0.08);
}
/* Rows fetch their own movie, so they behave like buttons. */
tbody tr[data-id] {
cursor: pointer;
}
tbody tr[data-id]:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.4);
outline-offset: -2px;
}
td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
vertical-align: top;
}
.movie-title {
font-weight: 500;
color: var(--on);
}
.num {
font-variant-numeric: tabular-nums;
color: var(--muted-2);
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--panel);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
/* ---- detail (GET /api/movies/:id) ------------------------------------ */
.panel-head.is-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.facts {
display: grid;
gap: 0.75rem;
margin: 0;
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.facts {
grid-template-columns: repeat(4, 1fr);
}
}
.facts dt {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.facts dd {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
}
.endpoint {
margin: 1rem 0 0;
font-size: 0.75rem;
color: var(--muted-3);
}
.link-btn {
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
text-decoration: underline;
text-underline-offset: 4px;
cursor: pointer;
transition: color 0.15s;
}
.link-btn:hover {
color: var(--on);
}
code {
padding: 0.1rem 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8125em;
color: rgba(255, 255, 255, 0.8);
}
/* ---- empty state ----------------------------------------------------- */
.empty {
padding: 3.5rem 1.5rem;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-xl);
background: var(--panel);
text-align: center;
}
.empty p {
margin: 0;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
}
.empty p + p {
margin-top: 0.25rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.35);
}
/* ---- footer ---------------------------------------------------------- */
footer {
width: 100%;
max-width: 48rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--muted-3);
}
footer a {
color: var(--muted-2);
text-decoration: underline;
text-underline-offset: 4px;
}
footer a:hover {
color: var(--on);
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="/">Movie API</a>
<div class="nav-links">
<a class="nav-link is-active" href="/" aria-current="page">collection</a>
<a class="nav-link" href="/api/movies" target="_blank" rel="noreferrer">api</a>
</div>
</div>
</nav>
<main>
<header class="page-head is-centered">
<h1>Movie Collection</h1>
<p class="subtitle" id="count">Loading…</p>
</header>
<p class="notice" id="notice" role="status" aria-live="polite" hidden></p>
<section class="panel" id="panel-form">
<div class="panel-head">
<h2>Add Movie</h2>
<p class="subtitle">Saved through <code>POST /api/movies</code>. The id is assigned by the server.</p>
</div>
<form id="movie-form" novalidate>
<div class="form-row">
<div class="field">
<label class="label" for="m-title">Title</label>
<input class="input" id="m-title" name="title" maxlength="120" placeholder="Inception" autocomplete="off">
<p class="error" id="err-title" hidden></p>
</div>
<div class="field">
<label class="label" for="m-genre">Genre</label>
<input class="input" id="m-genre" name="genre" maxlength="60" placeholder="science fiction" autocomplete="off">
<p class="error" id="err-genre" hidden></p>
</div>
<div class="field">
<label class="label" for="m-year">Year</label>
<input class="input" id="m-year" name="year" type="number" min="1888" max="2100" step="1" placeholder="2010" autocomplete="off">
<p class="error" id="err-year" hidden></p>
</div>
<div class="field">
<span class="label" aria-hidden="true">&nbsp;</span>
<button class="btn btn-primary" id="form-submit" type="submit">Add Movie</button>
</div>
</div>
</form>
</section>
<div class="section-head">
<h2>Movies</h2>
<button class="btn btn-ghost" id="load-movies" type="button">Load Movies</button>
</div>
<div class="table-wrap" id="table-wrap" hidden>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Genre</th>
<th>Year</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
<div class="empty" id="empty" hidden>
<p id="empty-title">No movies to show.</p>
<p id="empty-hint">Add one with the form above.</p>
</div>
<section class="panel" id="panel-detail" hidden aria-live="polite">
<div class="panel-head is-row">
<h2 id="detail-title">Movie</h2>
<button class="link-btn" id="detail-close" type="button">close</button>
</div>
<dl class="facts" id="detail-facts"></dl>
<p class="endpoint" id="detail-endpoint"></p>
</section>
</main>
<footer>
<span>Express + zod · <span id="movie-total">0</span> movies in memory</span>
<span><a href="/api/movies" target="_blank" rel="noreferrer">GET /api/movies</a> · records vanish on restart</span>
</footer>
<script>
const els = {
count: document.getElementById('count'),
notice: document.getElementById('notice'),
rows: document.getElementById('rows'),
tableWrap: document.getElementById('table-wrap'),
empty: document.getElementById('empty'),
emptyTitle: document.getElementById('empty-title'),
emptyHint: document.getElementById('empty-hint'),
total: document.getElementById('movie-total'),
form: document.getElementById('movie-form'),
submit: document.getElementById('form-submit'),
load: document.getElementById('load-movies'),
detail: document.getElementById('panel-detail'),
detailTitle: document.getElementById('detail-title'),
detailFacts: document.getElementById('detail-facts'),
detailEndpoint: document.getElementById('detail-endpoint'),
detailClose: document.getElementById('detail-close'),
fields: {
title: document.getElementById('m-title'),
genre: document.getElementById('m-genre'),
year: document.getElementById('m-year'),
},
}
const state = {
movies: [],
selectedId: null,
}
// ---- talking to the API -------------------------------------------------
/** fetch + JSON, never throwing on an error status. */
async function api(path, options = {}) {
const init = { ...options }
if (init.body !== undefined) {
init.headers = { 'content-type': 'application/json', ...init.headers }
init.body = JSON.stringify(init.body)
}
try {
const res = await fetch(path, init)
const text = await res.text()
let body = null
try {
body = text ? JSON.parse(text) : null
} catch {
body = text
}
return { ok: res.ok, status: res.status, body }
} catch {
return { ok: false, status: 0, body: { error: 'Could not reach the API. Is the server running?' } }
}
}
// ---- rendering ----------------------------------------------------------
/** Built with createElement/textContent so movie text can never inject markup. */
function row(movie) {
const tr = document.createElement('tr')
tr.dataset.id = movie.id
tr.tabIndex = 0
tr.setAttribute('role', 'button')
tr.setAttribute('aria-label', `Show ${movie.title} from GET /api/movies/${movie.id}`)
if (state.selectedId === movie.id) tr.className = 'is-selected'
const id = document.createElement('td')
id.className = 'num'
id.textContent = movie.id
const title = document.createElement('td')
const titleText = document.createElement('div')
titleText.className = 'movie-title'
titleText.textContent = movie.title
title.append(titleText)
const genre = document.createElement('td')
const badge = document.createElement('span')
badge.className = 'badge'
badge.textContent = movie.genre
genre.append(badge)
const year = document.createElement('td')
year.className = 'num'
year.textContent = movie.year
const open = () => showMovie(movie.id)
tr.addEventListener('click', open)
tr.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
open()
}
})
tr.append(id, title, genre, year)
return tr
}
function render() {
els.rows.replaceChildren(...state.movies.map(row))
els.tableWrap.hidden = state.movies.length === 0
els.empty.hidden = state.movies.length > 0
const n = state.movies.length
els.count.textContent = `${n} movie${n === 1 ? '' : 's'} in the collection.`
els.total.textContent = n
els.load.disabled = false
}
function setNotice(text, kind = 'info') {
els.notice.textContent = text
els.notice.className = kind === 'error' ? 'notice is-error' : 'notice'
els.notice.hidden = false
}
function clearNotice() {
els.notice.hidden = true
}
// ---- loading ------------------------------------------------------------
/** GET /api/movies — the whole collection. */
async function load() {
els.load.disabled = true
const { ok, body } = await api('/api/movies')
els.load.disabled = false
if (!ok) {
state.movies = []
render()
setNotice(body?.error ?? 'Could not load the collection.', 'error')
return false
}
state.movies = body
render()
return true
}
/** GET /api/movies/:id — one movie, fetched on its own. */
async function showMovie(id) {
const { ok, body } = await api(`/api/movies/${id}`)
if (!ok) {
state.selectedId = null
render()
els.detail.hidden = true
setNotice(body?.error ?? 'Could not load that movie.', 'error')
return
}
state.selectedId = id
render()
renderDetail(body)
}
function renderDetail(movie) {
els.detailTitle.textContent = movie.title
els.detailFacts.replaceChildren(...facts(movie))
els.detailEndpoint.replaceChildren(
document.createTextNode('Fetched from '),
code(`GET /api/movies/${movie.id}`),
)
els.detail.hidden = false
els.detail.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
function facts(movie) {
return [
['ID', String(movie.id)],
['Title', movie.title],
['Genre', movie.genre],
['Year', String(movie.year)],
].flatMap(([label, value]) => {
const dt = document.createElement('dt')
dt.textContent = label
const dd = document.createElement('dd')
dd.textContent = value
return [dt, dd]
})
}
function code(text) {
const el = document.createElement('code')
el.textContent = text
return el
}
els.detailClose.addEventListener('click', () => {
state.selectedId = null
els.detail.hidden = true
render()
})
els.load.addEventListener('click', async () => {
clearNotice()
await load()
})
// ---- create form --------------------------------------------------------
function clearFieldErrors() {
for (const key of Object.keys(els.fields)) {
const message = document.getElementById(`err-${key}`)
message.textContent = ''
message.hidden = true
}
}
function showFieldErrors(body) {
const fields = body?.fields ?? {}
let focused = false
for (const [key, message] of Object.entries(fields)) {
const target = document.getElementById(`err-${key}`)
if (!target) continue
target.textContent = message
target.hidden = false
if (!focused && els.fields[key]) {
els.fields[key].focus()
focused = true
}
}
setNotice(body?.error ?? 'Please fix the highlighted fields.', 'error')
}
function readForm() {
const year = els.fields.year.value.trim()
return {
title: els.fields.title.value,
genre: els.fields.genre.value,
// "" is sent as-is so the API answers "Year is required."
year: year === '' ? '' : Number(year),
}
}
els.form.addEventListener('submit', async (event) => {
event.preventDefault()
clearFieldErrors()
clearNotice()
const { ok, body } = await api('/api/movies', { method: 'POST', body: readForm() })
if (!ok) {
showFieldErrors(body)
return
}
els.form.reset()
// Refresh the list so the new row appears with its server-assigned id.
await load()
if (state.selectedId !== null) {
await showMovie(state.selectedId)
}
setNotice(`Movie added successfully — “${body.title}” is now movie #${body.id}.`)
els.fields.title.focus()
})
load()
</script>
</body>
</html>