Add movie collection API and frontend (#1)
This commit was merged in pull request #1.
This commit is contained in:
+872
@@ -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"> </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>
|
||||
Reference in New Issue
Block a user