840 lines
21 KiB
HTML
840 lines
21 KiB
HTML
<!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);
|
|
}
|
|
|
|
[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>
|
|
|
|
<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'),
|
|
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.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>
|