Initial commit

This commit is contained in:
2026-07-17 10:00:37 +01:00
commit 2840936b97
5 changed files with 2143 additions and 0 deletions

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# Media Tracker
A dependency-free web app for tracking movies, TV shows, books, and games.
## Run
Open `index.html` in a browser, or serve this folder with the included static server:
```powershell
node server.js
```
Then open `http://localhost:5173`.
## Data
The tracker stores items in browser `localStorage`. Use the Export and Import buttons to move or back up your collection.
## Search Sources
- Movies: Wikipedia through the MediaWiki Action API
- TV shows: TVmaze API
- Books: Open Library Search API
- Games: CheapShark API
If a search source is unavailable, add the item manually.

1034
app.js Normal file

File diff suppressed because it is too large Load Diff

135
index.html Normal file
View File

@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Media Tracker</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">Self-hosted collection</p>
<h1>Media Tracker</h1>
</div>
<div class="topbar-actions">
<button class="button subtle" id="exportButton" type="button">Export</button>
<label class="button subtle file-button" for="importInput">Import</label>
<input id="importInput" type="file" accept="application/json">
</div>
</header>
<main class="workspace" aria-live="polite">
<section class="search-panel" aria-labelledby="searchHeading">
<div class="section-heading">
<div>
<p class="eyebrow">Discover</p>
<h2 id="searchHeading">Search media</h2>
</div>
</div>
<div class="type-tabs" role="tablist" aria-label="Media type">
<button class="type-tab active" type="button" data-type="movie">Movies</button>
<button class="type-tab" type="button" data-type="tv">TV Shows</button>
<button class="type-tab" type="button" data-type="book">Books</button>
<button class="type-tab" type="button" data-type="game">Games</button>
</div>
<form class="search-form" id="searchForm">
<label for="searchInput">Search</label>
<div class="search-row">
<input id="searchInput" name="query" type="search" autocomplete="off" placeholder="Blade Runner, Silo, Dune, Hades">
<button class="button primary" type="submit">Search</button>
</div>
</form>
<div class="manual-add">
<button class="text-button" id="toggleManualButton" type="button" aria-expanded="false">Add manually</button>
<form class="manual-form hidden" id="manualForm">
<div class="field-grid">
<label>
Title
<input id="manualTitle" type="text" required>
</label>
<label>
Year
<input id="manualYear" type="text" inputmode="numeric" maxlength="4">
</label>
<label>
Creator
<input id="manualCreator" type="text" placeholder="Director, author, studio">
</label>
<label>
Cover URL
<input id="manualCover" type="url" placeholder="https://">
</label>
</div>
<label>
Notes
<textarea id="manualNotes" rows="3"></textarea>
</label>
<div class="manual-actions">
<button class="button primary" type="submit">Add item</button>
</div>
</form>
</div>
<div class="search-feedback" id="searchFeedback">Ready</div>
<div class="result-grid" id="results"></div>
</section>
<section class="tracker-panel" aria-labelledby="trackerHeading">
<div class="section-heading">
<div>
<p class="eyebrow">Collection</p>
<h2 id="trackerHeading">Tracker</h2>
</div>
<div class="summary-strip" id="summary"></div>
</div>
<div class="filters">
<label>
Type
<select id="typeFilter">
<option value="all">All media</option>
<option value="movie">Movies</option>
<option value="tv">TV Shows</option>
<option value="book">Books</option>
<option value="game">Games</option>
</select>
</label>
<label>
Progress
<select id="progressFilter">
<option value="all">All progress</option>
<option value="planned">No dates</option>
<option value="active">In progress</option>
<option value="complete">Finished</option>
</select>
</label>
<label>
Find
<input id="collectionSearch" type="search" placeholder="Filter collection">
</label>
<label>
Sort
<select id="sortSelect">
<option value="recent">Recently added</option>
<option value="updated">Recently updated</option>
<option value="finished">Recently finished</option>
<option value="started">Recently started</option>
<option value="title">Title</option>
</select>
</label>
</div>
<div class="collection-list" id="collection"></div>
</section>
</main>
</div>
<div class="toast hidden" id="toast" role="status"></div>
<script src="app.js" defer></script>
</body>
</html>

50
server.js Normal file
View File

@@ -0,0 +1,50 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const root = __dirname;
const port = Number(process.env.PORT || 5173);
const contentTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
};
const server = http.createServer((request, response) => {
const urlPath = getUrlPath(request.url);
const requestedPath = urlPath === "/" ? "/index.html" : urlPath;
const filePath = path.resolve(root, `.${requestedPath}`);
if (!filePath.startsWith(root)) {
response.writeHead(403);
response.end("Forbidden");
return;
}
fs.readFile(filePath, (error, data) => {
if (error) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"Content-Type": contentTypes[path.extname(filePath)] || "application/octet-stream",
});
response.end(data);
});
});
server.listen(port, () => {
console.log(`Media Tracker running at http://localhost:${port}`);
});
function getUrlPath(value) {
try {
return decodeURIComponent(new URL(value, "http://localhost").pathname);
} catch (error) {
return "/";
}
}

898
styles.css Normal file
View File

@@ -0,0 +1,898 @@
:root {
--bg: #14181c;
--bg-deep: #0b1015;
--surface: #1f2832;
--surface-raised: #26313d;
--surface-muted: #2f3a46;
--ink: #d8e0e8;
--ink-strong: #ffffff;
--muted: #8f9aa6;
--line: #34414f;
--line-strong: #4a5868;
--green: #00c030;
--green-soft: rgba(0, 192, 48, 0.16);
--orange: #ff8000;
--orange-soft: rgba(255, 128, 0, 0.16);
--blue: #40bcf4;
--blue-soft: rgba(64, 188, 244, 0.16);
--red: #ff5c57;
--shadow: 0 18px 38px rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
html {
color-scheme: dark;
}
body {
margin: 0;
min-width: 320px;
color: var(--ink);
background:
linear-gradient(180deg, #1c2630 0, var(--bg) 220px, var(--bg-deep) 100%);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app-shell {
min-height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px clamp(16px, 4vw, 40px);
background: rgba(20, 24, 28, 0.94);
border-bottom: 1px solid #2c3440;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
}
.topbar > div:first-child {
min-width: 0;
}
.topbar h1,
.section-heading h2 {
margin: 0;
letter-spacing: 0;
}
.topbar h1 {
display: flex;
align-items: center;
gap: 12px;
color: var(--ink-strong);
font-size: clamp(1.55rem, 3vw, 2.35rem);
line-height: 1;
}
.topbar h1::before {
content: "";
width: 11px;
height: 11px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--orange);
box-shadow: 16px 0 0 var(--green), 32px 0 0 var(--blue);
margin-right: 28px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.topbar-actions,
.manual-actions {
display: flex;
align-items: center;
gap: 10px;
}
.file-button {
display: inline-flex;
}
#importInput {
display: none;
}
.workspace {
display: grid;
grid-template-columns: minmax(330px, 0.85fr) minmax(500px, 1.5fr);
gap: 24px;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 44px;
}
.search-panel,
.tracker-panel {
min-width: 0;
}
.section-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.section-heading h2 {
color: var(--ink-strong);
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
}
.type-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin-bottom: 12px;
padding: 4px;
border-radius: 8px;
background: var(--bg-deep);
border: 1px solid #26313d;
}
.type-tab,
.button,
.text-button {
min-height: 38px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--surface);
color: var(--ink);
font-weight: 800;
}
.type-tab {
padding: 8px 10px;
background: transparent;
color: var(--muted);
}
.type-tab.active {
border-color: rgba(0, 192, 48, 0.5);
color: var(--ink-strong);
background: var(--green-soft);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 14px;
text-decoration: none;
white-space: nowrap;
}
.button.primary {
border-color: #00a82a;
background: var(--green);
color: #06250e;
}
.button.primary:hover {
border-color: #20d04c;
background: #21d94e;
}
.button.primary:disabled {
cursor: default;
border-color: var(--line);
background: var(--surface-muted);
color: var(--muted);
}
.button.subtle {
border-color: var(--line);
background: transparent;
}
.button.subtle:hover,
.type-tab:hover,
.text-button:hover {
border-color: var(--line-strong);
color: var(--ink-strong);
}
.text-button {
min-height: 34px;
padding: 0;
border: 0;
background: transparent;
color: var(--blue);
}
.search-form,
.manual-add,
.filters,
.result-card,
.collection-card,
.empty-state {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(31, 40, 50, 0.94);
box-shadow: var(--shadow);
}
.search-form,
.manual-add {
padding: 14px;
}
.manual-add {
margin-top: 12px;
}
.search-form label,
.filters label,
.manual-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.search-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #111820;
color: var(--ink-strong);
padding: 10px 11px;
}
input::placeholder,
textarea::placeholder {
color: #687684;
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible {
outline: 3px solid rgba(64, 188, 244, 0.22);
outline-offset: 2px;
}
textarea {
resize: vertical;
}
.manual-form {
display: grid;
gap: 12px;
margin-top: 10px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.hidden {
display: none !important;
}
.search-feedback {
min-height: 26px;
margin: 12px 0 8px;
color: var(--muted);
font-size: 0.88rem;
}
.result-grid {
display: grid;
gap: 12px;
}
.result-card {
display: grid;
grid-template-columns: 86px 1fr;
gap: 13px;
padding: 10px;
transition: border-color 160ms ease, transform 160ms ease, background 160ms ease;
}
.result-card:hover,
.collection-card:hover {
border-color: var(--line-strong);
background: var(--surface-raised);
}
.result-card:hover {
transform: translateY(-1px);
}
.cover,
.cover-placeholder {
width: 86px;
aspect-ratio: 2 / 3;
border-radius: 6px;
object-fit: cover;
background: #121a22;
border: 1px solid var(--line);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
}
.cover-placeholder {
display: grid;
place-items: center;
padding: 8px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 900;
text-align: center;
}
.item-body {
min-width: 0;
}
.item-title-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 10px;
}
.item-title {
margin: 0;
color: var(--ink-strong);
font-size: 1rem;
line-height: 1.25;
}
.item-meta,
.item-description,
.date-line,
.progress-line {
margin: 5px 0 0;
color: var(--muted);
font-size: 0.84rem;
line-height: 1.4;
}
.progress-line {
color: var(--green);
font-weight: 850;
}
.progress-planned .progress-line {
color: var(--blue);
}
.progress-active .progress-line {
color: var(--orange);
}
.progress-complete .progress-line {
color: var(--green);
}
.item-description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 23px;
padding: 3px 8px;
border-radius: 999px;
background: var(--surface-muted);
color: var(--muted);
font-size: 0.7rem;
font-weight: 900;
white-space: nowrap;
}
.badge.movie {
background: var(--orange-soft);
color: #ffb366;
}
.badge.tv {
background: var(--green-soft);
color: #40dc63;
}
.badge.book {
background: var(--blue-soft);
color: #79d4ff;
}
.badge.game {
background: rgba(202, 152, 255, 0.18);
color: #c9a6ff;
}
.result-actions,
.collection-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.link-action {
color: var(--blue);
font-size: 0.84rem;
font-weight: 800;
text-decoration: none;
}
.link-action:hover {
color: #8bdaff;
text-decoration: underline;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(3, minmax(62px, 1fr));
gap: 8px;
}
.summary-pill {
min-width: 62px;
border: 1px solid var(--line);
border-radius: 6px;
background: #111820;
padding: 8px 10px;
text-align: center;
}
.summary-pill:nth-child(1) {
border-top-color: var(--orange);
}
.summary-pill:nth-child(2) {
border-top-color: var(--blue);
}
.summary-pill:nth-child(3) {
border-top-color: var(--green);
}
.summary-pill strong {
display: block;
color: var(--ink-strong);
font-size: 1.05rem;
}
.summary-pill span {
color: var(--muted);
font-size: 0.7rem;
font-weight: 800;
text-transform: uppercase;
}
.filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 12px;
margin-bottom: 14px;
box-shadow: none;
}
.collection-list {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.collection-card {
position: relative;
display: grid;
grid-template-columns: clamp(124px, 18vw, 164px) minmax(0, 1fr);
min-width: 0;
overflow: hidden;
padding: 0;
transition: border-color 160ms ease, transform 160ms ease, background 160ms ease;
}
.collection-card:hover {
transform: translateY(-2px);
}
.collection-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
height: 100%;
background: var(--green);
z-index: 1;
}
.collection-card.progress-planned::before {
background: var(--blue);
}
.collection-card.progress-active::before {
background: var(--orange);
}
.collection-card.progress-complete::before {
background: var(--green);
}
.collection-card .cover,
.collection-card .cover-placeholder {
grid-row: 1 / span 2;
width: 100%;
height: 100%;
min-height: 186px;
border: 0;
border-radius: 6px 0 0 6px;
object-fit: cover;
box-shadow: none;
}
.collection-card .item-body {
display: grid;
align-content: start;
gap: 10px;
padding: 14px 16px;
}
.collection-card .item-title-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 8px;
}
.collection-card .item-description {
-webkit-line-clamp: 2;
}
.season-panel {
display: grid;
grid-column: 2;
gap: 8px;
margin: 0 16px 14px 0;
padding: 9px;
border: 1px solid rgba(64, 188, 244, 0.16);
border-radius: 6px;
background: rgba(17, 24, 32, 0.78);
}
.season-summary {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 0.74rem;
font-weight: 850;
text-transform: uppercase;
}
.season-summary span:last-child {
color: var(--blue);
text-transform: none;
}
.season-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 6px;
max-height: 240px;
overflow: auto;
padding-right: 2px;
}
.season-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) repeat(2, minmax(116px, 140px));
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 7px;
border: 1px solid transparent;
border-radius: 6px;
background: rgba(38, 49, 61, 0.7);
color: var(--ink);
font-size: 0.78rem;
}
.season-row:hover {
border-color: var(--line-strong);
}
.season-row.progress-complete {
border-color: rgba(0, 192, 48, 0.34);
background: var(--green-soft);
}
.season-row.progress-active {
border-color: rgba(255, 128, 0, 0.32);
background: var(--orange-soft);
}
.season-title {
display: grid;
gap: 3px;
min-width: 0;
}
.season-row input[type="date"] {
width: 100%;
}
.season-name {
min-width: 0;
color: var(--ink-strong);
font-weight: 850;
line-height: 1.2;
}
.season-meta {
color: var(--muted);
font-size: 0.72rem;
white-space: nowrap;
}
.season-runtime {
color: var(--blue);
font-size: 0.72rem;
font-weight: 850;
white-space: nowrap;
}
.season-empty {
margin: 0;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.4;
}
.season-load-button {
width: 100%;
}
.date-grid {
display: grid;
grid-template-columns: repeat(2, minmax(140px, 1fr));
gap: 8px;
}
.date-grid label,
.season-date-field {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 850;
text-transform: uppercase;
}
.date-grid input,
.season-date-field input {
min-height: 34px;
padding: 7px 8px;
}
.collection-actions {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto auto;
align-items: center;
}
.notes-input {
min-height: 36px;
}
.remove-button {
border-color: rgba(255, 92, 87, 0.34);
color: #ff938f;
}
.remove-button:hover {
border-color: var(--red);
color: #ffd6d4;
}
.empty-state {
grid-column: 1 / -1;
padding: 18px;
color: var(--muted);
}
.empty-state h3 {
margin: 0 0 6px;
color: var(--ink-strong);
}
.empty-state p {
margin: 0;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
max-width: min(360px, calc(100vw - 32px));
border-radius: 8px;
background: #e7eef5;
color: #101820;
padding: 12px 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
z-index: 20;
}
@media (max-width: 1060px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.topbar,
.section-heading {
align-items: stretch;
flex-direction: column;
}
.topbar-actions,
.manual-actions {
width: 100%;
}
.topbar-actions .button,
.manual-actions .button,
.manual-actions select {
flex: 1 1 0;
}
.type-tabs,
.filters,
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.search-row {
grid-template-columns: 1fr;
}
.result-card {
grid-template-columns: 72px 1fr;
}
.result-card .cover,
.result-card .cover-placeholder {
width: 72px;
}
.collection-card {
grid-template-columns: 112px minmax(0, 1fr);
}
.collection-card .cover,
.collection-card .cover-placeholder {
grid-row: 1;
min-height: 168px;
}
.collection-card .item-body {
padding: 12px;
}
.season-list {
grid-template-columns: 1fr;
max-height: 190px;
}
.season-panel {
grid-column: 1 / -1;
margin: 0 12px 12px;
}
.collection-actions {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.workspace {
width: min(100% - 24px, 1180px);
}
.type-tabs,
.filters,
.field-grid {
grid-template-columns: 1fr;
}
.collection-list {
grid-template-columns: 1fr;
gap: 10px;
}
.collection-card {
grid-template-columns: 96px minmax(0, 1fr);
}
.collection-card .cover,
.collection-card .cover-placeholder {
min-height: 146px;
}
.collection-card .item-title-row {
align-items: start;
flex-direction: column;
}
.collection-card .item-description {
-webkit-line-clamp: 2;
}
.date-grid {
grid-template-columns: 1fr;
}
.season-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.season-title {
grid-column: 1 / -1;
}
.result-actions,
.collection-actions {
align-items: stretch;
flex-direction: column;
}
.button,
.date-grid,
.notes-input {
width: 100%;
max-width: none;
}
}