From 2840936b973fca012756e55aeeecf0e14850eeef Mon Sep 17 00:00:00 2001 From: Luke Betteridge Date: Fri, 17 Jul 2026 10:00:37 +0100 Subject: [PATCH] Initial commit --- README.md | 26 ++ app.js | 1034 ++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 135 +++++++ server.js | 50 +++ styles.css | 898 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 2143 insertions(+) create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 server.js create mode 100644 styles.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..8315c70 --- /dev/null +++ b/README.md @@ -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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..fba7a1b --- /dev/null +++ b/app.js @@ -0,0 +1,1034 @@ +const STORAGE_KEY = "media-tracker-items-v1"; + +const mediaTypes = { + movie: { + label: "Movies", + singular: "Movie", + badge: "movie", + progressLabels: { + planned: "No watch date", + active: "Started watching", + complete: "Watched", + }, + }, + tv: { + label: "TV Shows", + singular: "TV Show", + badge: "tv", + progressLabels: { + planned: "No dates", + active: "Watching", + complete: "Finished", + }, + }, + book: { + label: "Books", + singular: "Book", + badge: "book", + progressLabels: { + planned: "No read date", + active: "Reading", + complete: "Read", + }, + }, + game: { + label: "Games", + singular: "Game", + badge: "game", + progressLabels: { + planned: "No play date", + active: "Playing", + complete: "Completed", + }, + }, +}; + +const state = { + activeType: "movie", + results: [], + collection: loadCollection(), + filters: { + type: "all", + progress: "all", + search: "", + sort: "recent", + }, +}; + +const elements = { + typeTabs: document.querySelectorAll(".type-tab"), + searchForm: document.getElementById("searchForm"), + searchInput: document.getElementById("searchInput"), + searchFeedback: document.getElementById("searchFeedback"), + results: document.getElementById("results"), + toggleManualButton: document.getElementById("toggleManualButton"), + manualForm: document.getElementById("manualForm"), + manualTitle: document.getElementById("manualTitle"), + manualYear: document.getElementById("manualYear"), + manualCreator: document.getElementById("manualCreator"), + manualCover: document.getElementById("manualCover"), + manualNotes: document.getElementById("manualNotes"), + typeFilter: document.getElementById("typeFilter"), + progressFilter: document.getElementById("progressFilter"), + collectionSearch: document.getElementById("collectionSearch"), + sortSelect: document.getElementById("sortSelect"), + summary: document.getElementById("summary"), + collection: document.getElementById("collection"), + exportButton: document.getElementById("exportButton"), + importInput: document.getElementById("importInput"), + toast: document.getElementById("toast"), +}; + +initialize(); + +function initialize() { + elements.typeTabs.forEach((tab) => { + tab.addEventListener("click", () => setActiveType(tab.dataset.type)); + }); + + elements.searchForm.addEventListener("submit", handleSearch); + elements.toggleManualButton.addEventListener("click", toggleManualForm); + elements.manualForm.addEventListener("submit", handleManualAdd); + elements.results.addEventListener("click", handleResultAction); + elements.collection.addEventListener("change", handleCollectionChange); + elements.collection.addEventListener("click", handleCollectionClick); + elements.collection.addEventListener("input", handleCollectionInput); + elements.typeFilter.addEventListener("change", updateFilters); + elements.progressFilter.addEventListener("change", updateFilters); + elements.collectionSearch.addEventListener("input", updateFilters); + elements.sortSelect.addEventListener("change", updateFilters); + elements.exportButton.addEventListener("click", exportCollection); + elements.importInput.addEventListener("change", importCollection); + + renderResults(); + renderCollection(); +} + +function setActiveType(type) { + if (!mediaTypes[type]) { + return; + } + + state.activeType = type; + state.results = []; + + elements.typeTabs.forEach((tab) => { + tab.classList.toggle("active", tab.dataset.type === type); + }); + + setSearchFeedback("Ready"); + renderResults(); +} + +async function handleSearch(event) { + event.preventDefault(); + const query = elements.searchInput.value.trim(); + + if (!query) { + setSearchFeedback("Enter a title to search."); + return; + } + + setSearchFeedback(`Searching ${mediaTypes[state.activeType].label.toLowerCase()}...`); + elements.results.innerHTML = ""; + + try { + const results = await searchMedia(state.activeType, query); + state.results = results.slice(0, 12); + setSearchFeedback(results.length ? `${results.length} results found.` : "No matches found."); + } catch (error) { + state.results = []; + setSearchFeedback(`Search failed for ${mediaTypes[state.activeType].label}. Add it manually instead.`); + } + + renderResults(); +} + +async function searchMedia(type, query) { + const adapters = { + movie: searchMovies, + tv: searchTv, + book: searchBooks, + game: searchGames, + }; + + return adapters[type](query); +} + +async function searchMovies(query) { + const url = new URL("https://en.wikipedia.org/w/api.php"); + url.searchParams.set("action", "query"); + url.searchParams.set("generator", "search"); + url.searchParams.set("gsrsearch", `${query} film`); + url.searchParams.set("gsrlimit", "12"); + url.searchParams.set("prop", "pageimages|extracts"); + url.searchParams.set("exintro", "1"); + url.searchParams.set("explaintext", "1"); + url.searchParams.set("exchars", "260"); + url.searchParams.set("piprop", "thumbnail"); + url.searchParams.set("pithumbsize", "300"); + url.searchParams.set("format", "json"); + url.searchParams.set("origin", "*"); + + const data = await fetchJson(url); + const pages = Object.values(data.query?.pages || {}).sort((a, b) => a.index - b.index); + const filmLikePages = pages.filter((page) => { + const text = `${page.title} ${page.extract || ""}`.toLowerCase(); + return text.includes(" film") || text.includes("(film)") || text.includes(" movie"); + }); + + return (filmLikePages.length ? filmLikePages : pages).map((movie) => ({ + type: "movie", + source: "wikipedia", + sourceId: String(movie.pageid), + title: movie.title, + year: getYearFromText(movie.extract), + creator: "Wikipedia", + cover: movie.thumbnail?.source || "", + description: movie.extract || "", + externalUrl: `https://en.wikipedia.org/?curid=${movie.pageid}`, + })); +} + +async function searchTv(query) { + const url = new URL("https://api.tvmaze.com/search/shows"); + url.searchParams.set("q", query); + + const data = await fetchJson(url); + + return data.map(({ show }) => ({ + type: "tv", + source: "tvmaze", + sourceId: String(show.id), + title: show.name, + year: getYear(show.premiered), + creator: [show.network?.name, show.webChannel?.name, show.status].filter(Boolean).join(" / ") || "TV Show", + cover: show.image?.medium || show.image?.original || "", + description: stripHtml(show.summary || ""), + externalUrl: show.url || "", + runtimeMinutes: show.averageRuntime || show.runtime || null, + })); +} + +async function searchBooks(query) { + const url = new URL("https://openlibrary.org/search.json"); + url.searchParams.set("title", query); + url.searchParams.set("limit", "12"); + + const data = await fetchJson(url); + + return (data.docs || []).map((book) => ({ + type: "book", + source: "openlibrary", + sourceId: book.key || `${book.title}-${book.first_publish_year || ""}`, + title: book.title, + year: book.first_publish_year || "", + creator: Array.isArray(book.author_name) ? book.author_name.slice(0, 2).join(", ") : "Book", + cover: book.cover_i ? `https://covers.openlibrary.org/b/id/${book.cover_i}-M.jpg` : "", + description: [ + book.edition_count ? `${book.edition_count} editions` : "", + Array.isArray(book.language) ? `${book.language.length} languages` : "", + ].filter(Boolean).join(" / "), + externalUrl: book.key ? `https://openlibrary.org${book.key}` : "", + })); +} + +async function searchGames(query) { + const url = new URL("https://www.cheapshark.com/api/1.0/games"); + url.searchParams.set("title", query); + url.searchParams.set("limit", "12"); + + const data = await fetchJson(url); + + return data.map((game) => ({ + type: "game", + source: "cheapshark", + sourceId: String(game.gameID || game.steamAppID || game.external), + title: game.external, + year: "", + creator: game.steamAppID ? "Steam" : "Game", + cover: normalizeImageUrl(game.thumb || ""), + description: game.cheapest ? `Lowest tracked price: $${game.cheapest}` : "", + externalUrl: game.steamAppID ? `https://store.steampowered.com/app/${game.steamAppID}` : "", + })); +} + +async function fetchJson(url) { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return response.json(); +} + +function renderResults() { + if (!state.results.length) { + elements.results.innerHTML = ""; + return; + } + + elements.results.innerHTML = state.results.map(renderResultCard).join(""); + bindCoverFallbacks(elements.results); +} + +function renderResultCard(item, index) { + const type = mediaTypes[item.type]; + const alreadyAdded = isInCollection(item); + const sourceLink = item.externalUrl + ? `Source` + : ""; + + return ` +
+ ${renderCover(item.cover, item.title)} +
+
+
+

${escapeHtml(item.title)}

+

${escapeHtml(formatMeta(item))}

+
+ ${type.singular} +
+ ${item.description ? `

${escapeHtml(item.description)}

` : ""} +
+ + ${sourceLink} +
+
+
+ `; +} + +function renderCollection() { + renderSummary(); + + const items = getFilteredCollection(); + + if (!items.length) { + elements.collection.innerHTML = ` +
+

No tracked items

+

Your collection will appear here.

+
+ `; + return; + } + + elements.collection.innerHTML = items.map(renderCollectionCard).join(""); + bindCoverFallbacks(elements.collection); +} + +function renderSummary() { + const totals = state.collection.reduce( + (summary, item) => { + const progress = getItemProgress(item); + summary.total += 1; + summary[progress.key] += 1; + return summary; + }, + { total: 0, planned: 0, active: 0, complete: 0 }, + ); + + elements.summary.innerHTML = ` +
${totals.total}Total
+
${totals.active}Started
+
${totals.complete}Finished
+ `; +} + +function renderCollectionCard(item) { + const type = mediaTypes[item.type]; + const progress = getItemProgress(item); + const sourceLink = item.externalUrl + ? `Source` + : ""; + + return ` +
+ ${renderCover(item.cover, item.title)} +
+
+
+

${escapeHtml(item.title)}

+

${escapeHtml(formatMeta(item))}

+

${escapeHtml(getProgressText(item))}

+
+ ${type.singular} +
+ ${item.description ? `

${escapeHtml(item.description)}

` : ""} + + ${renderItemDateFields(item)} +
+ + ${sourceLink} + +
+
+ ${renderSeasonTracker(item)} +
+ `; +} + +function renderItemDateFields(item) { + return ` +
+ + +
+ `; +} + +function renderSeasonTracker(item) { + if (item.type !== "tv") { + return ""; + } + + const seasons = normalizeSeasonData(item.seasons); + + if (!seasons.length) { + const canLoadSeasons = item.source === "tvmaze" && item.sourceId; + return ` +
+

${canLoadSeasons ? "Season data not loaded yet." : "Season tracking needs a TVMaze result."}

+ ${canLoadSeasons ? '' : ""} +
+ `; + } + + const finishedCount = seasons.filter((season) => getSeasonProgress(season).key === "complete").length; + const totalRuntime = seasons.reduce((sum, season) => sum + (season.runtimeMinutes || 0), 0); + + return ` +
+
+ ${finishedCount}/${seasons.length} seasons finished + ${formatRuntime(totalRuntime)} +
+
+ ${seasons.map(renderSeasonRow).join("")} +
+
+ `; +} + +function renderSeasonRow(season) { + const seasonLabel = season.number === 0 ? "Specials" : `Season ${season.number}`; + const episodeLabel = `${season.episodeCount || 0} ep${season.episodeCount === 1 ? "" : "s"}`; + const progress = getSeasonProgress(season); + + return ` +
+
+ ${escapeHtml(seasonLabel)} + ${escapeHtml(episodeLabel)} / ${escapeHtml(formatRuntime(season.runtimeMinutes))} +
+ + +
+ `; +} + +function renderCover(cover, title) { + if (cover) { + return `${escapeAttribute(title)} cover`; + } + + return `
${escapeHtml(getInitials(title))}
`; +} + +function bindCoverFallbacks(container) { + container.querySelectorAll("img[data-fallback-initials]").forEach((image) => { + image.addEventListener("error", () => { + image.replaceWith(createCoverPlaceholder(image.dataset.fallbackInitials || "?")); + }, { once: true }); + }); +} + +function createCoverPlaceholder(initials) { + const placeholder = document.createElement("div"); + placeholder.className = "cover-placeholder"; + placeholder.textContent = initials; + return placeholder; +} + +async function handleResultAction(event) { + const button = event.target.closest("[data-add-result]"); + if (!button) { + return; + } + + const item = state.results[Number(button.dataset.addResult)]; + button.disabled = true; + button.textContent = item.type === "tv" ? "Loading..." : "Adding..."; + + try { + await addToCollection(item); + } finally { + renderResults(); + } +} + +async function handleManualAdd(event) { + event.preventDefault(); + + const title = elements.manualTitle.value.trim(); + if (!title) { + return; + } + + await addToCollection({ + type: state.activeType, + source: "manual", + sourceId: createId(), + title, + year: elements.manualYear.value.trim(), + creator: elements.manualCreator.value.trim(), + cover: elements.manualCover.value.trim(), + description: elements.manualNotes.value.trim(), + externalUrl: "", + notes: elements.manualNotes.value.trim(), + }); + + elements.manualForm.reset(); +} + +async function addToCollection(item) { + if (isInCollection(item)) { + showToast(`${item.title} is already tracked.`); + return; + } + + const now = new Date().toISOString(); + const collectionItem = { + id: createId(), + type: item.type, + source: item.source || "manual", + sourceId: item.sourceId || createId(), + title: item.title, + year: item.year || "", + creator: item.creator || "", + cover: item.cover || "", + description: item.description || "", + externalUrl: item.externalUrl || "", + startedAt: normalizeDateValue(item.startedAt), + finishedAt: normalizeDateValue(item.finishedAt), + notes: item.notes || "", + seasons: normalizeSeasonData(item.seasons), + addedAt: now, + updatedAt: now, + }; + + if (collectionItem.type === "tv" && collectionItem.source === "tvmaze" && collectionItem.sourceId) { + try { + collectionItem.seasons = await fetchTvSeasons(collectionItem.sourceId); + } catch (error) { + collectionItem.seasons = []; + } + } + + state.collection.unshift(collectionItem); + persistCollection(); + renderResults(); + renderCollection(); + const seasonMessage = collectionItem.seasons.length ? ` with ${collectionItem.seasons.length} seasons` : ""; + showToast(`${item.title} added${seasonMessage}.`); +} + +function handleCollectionChange(event) { + const card = event.target.closest("[data-item-id]"); + if (!card) { + return; + } + + if (event.target.matches("[data-date-field]")) { + updateItem(card.dataset.itemId, { + [event.target.dataset.dateField]: normalizeDateValue(event.target.value), + }); + } + + if (event.target.matches("[data-season-date-field]")) { + const seasonNumber = Number(event.target.dataset.seasonNumber); + const field = event.target.dataset.seasonDateField; + const item = state.collection.find((entry) => entry.id === card.dataset.itemId); + const seasons = normalizeSeasonData(item?.seasons).map((season) => ( + season.number === seasonNumber + ? { ...season, [field]: normalizeDateValue(event.target.value) } + : season + )); + + updateItem(card.dataset.itemId, { seasons }); + } +} + +function handleCollectionInput(event) { + const card = event.target.closest("[data-item-id]"); + if (!card || !event.target.matches("[data-notes-input]")) { + return; + } + + updateItem(card.dataset.itemId, { + notes: event.target.value, + }, false); +} + +async function handleCollectionClick(event) { + const loadButton = event.target.closest("[data-load-seasons]"); + if (loadButton) { + const card = loadButton.closest("[data-item-id]"); + loadButton.disabled = true; + loadButton.textContent = "Loading..."; + await loadSeasonsForItem(card.dataset.itemId); + return; + } + + const removeButton = event.target.closest("[data-remove-item]"); + if (!removeButton) { + return; + } + + const card = removeButton.closest("[data-item-id]"); + const item = state.collection.find((entry) => entry.id === card.dataset.itemId); + state.collection = state.collection.filter((entry) => entry.id !== card.dataset.itemId); + persistCollection(); + renderCollection(); + showToast(`${item?.title || "Item"} removed.`); +} + +async function loadSeasonsForItem(id) { + const item = state.collection.find((entry) => entry.id === id); + if (!item || item.type !== "tv" || item.source !== "tvmaze" || !item.sourceId) { + showToast("Season data is only available for TVMaze shows."); + renderCollection(); + return; + } + + try { + const seasons = await fetchTvSeasons(item.sourceId, item.seasons); + updateItem(id, { seasons }); + showToast(`${item.title} seasons loaded.`); + } catch (error) { + showToast("Could not load season data right now."); + renderCollection(); + } +} + +function updateItem(id, changes, rerender = true) { + state.collection = state.collection.map((item) => { + if (item.id !== id) { + return item; + } + + return { + ...item, + ...changes, + updatedAt: new Date().toISOString(), + }; + }); + + persistCollection(); + + if (rerender) { + renderCollection(); + } +} + +function updateFilters() { + state.filters = { + type: elements.typeFilter.value, + progress: elements.progressFilter.value, + search: elements.collectionSearch.value.trim().toLowerCase(), + sort: elements.sortSelect.value, + }; + + renderCollection(); +} + +function getFilteredCollection() { + const { type, progress, search, sort } = state.filters; + + const items = state.collection.filter((item) => { + const matchesType = type === "all" || item.type === type; + const matchesProgress = progress === "all" || getItemProgress(item).key === progress; + const haystack = [item.title, item.creator, item.year, item.notes].join(" ").toLowerCase(); + const matchesSearch = !search || haystack.includes(search); + return matchesType && matchesProgress && matchesSearch; + }); + + return items.sort((a, b) => { + if (sort === "title") { + return a.title.localeCompare(b.title); + } + + if (sort === "started") { + return compareDates(b.startedAt, a.startedAt); + } + + if (sort === "finished") { + return compareDates(b.finishedAt, a.finishedAt); + } + + const field = sort === "updated" ? "updatedAt" : "addedAt"; + return compareDates(b[field], a[field]); + }); +} + +function toggleManualForm() { + const isHidden = elements.manualForm.classList.toggle("hidden"); + elements.toggleManualButton.setAttribute("aria-expanded", String(!isHidden)); +} + +function exportCollection() { + const payload = { + exportedAt: new Date().toISOString(), + items: state.collection, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `media-tracker-${new Date().toISOString().slice(0, 10)}.json`; + link.click(); + URL.revokeObjectURL(url); +} + +async function importCollection(event) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + try { + const text = await file.text(); + const data = JSON.parse(text); + const imported = Array.isArray(data) ? data : data.items; + + if (!Array.isArray(imported)) { + throw new Error("Invalid import file."); + } + + const normalized = imported + .filter((item) => item && mediaTypes[item.type] && item.title) + .map((item) => ({ + id: item.id || createId(), + type: item.type, + source: item.source || "manual", + sourceId: item.sourceId || createId(), + title: item.title, + year: item.year || "", + creator: item.creator || "", + cover: item.cover || "", + description: item.description || "", + externalUrl: item.externalUrl || "", + startedAt: normalizeDateValue(item.startedAt), + finishedAt: normalizeDateValue(item.finishedAt), + notes: item.notes || "", + seasons: normalizeSeasonData(item.seasons), + addedAt: item.addedAt || new Date().toISOString(), + updatedAt: item.updatedAt || new Date().toISOString(), + })); + + state.collection = mergeCollections(state.collection, normalized); + persistCollection(); + renderCollection(); + showToast(`${normalized.length} items imported.`); + } catch (error) { + showToast("Import failed. Choose a tracker JSON file."); + } finally { + event.target.value = ""; + } +} + +function mergeCollections(existing, imported) { + const seen = new Set(existing.map(getCollectionKey)); + const additions = imported.filter((item) => { + const key = getCollectionKey(item); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + return [...additions, ...existing]; +} + +function isInCollection(item) { + const key = getCollectionKey(item); + return state.collection.some((entry) => getCollectionKey(entry) === key); +} + +function getCollectionKey(item) { + const sourceKey = item.source && item.source !== "manual" && item.sourceId ? `${item.source}:${item.sourceId}` : ""; + return `${item.type}:${sourceKey || normalizeTitle(item.title)}`; +} + +function persistCollection() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.collection)); +} + +function loadCollection() { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); + return Array.isArray(saved) ? saved : []; + } catch (error) { + return []; + } +} + +function setSearchFeedback(message) { + elements.searchFeedback.textContent = message; +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.classList.remove("hidden"); + clearTimeout(showToast.timeoutId); + showToast.timeoutId = setTimeout(() => { + elements.toast.classList.add("hidden"); + }, 2600); +} + +function formatMeta(item) { + return [item.year, item.creator].filter(Boolean).join(" / ") || mediaTypes[item.type].singular; +} + +function formatDate(value) { + if (!value) { + return "unknown"; + } + + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); +} + +async function fetchTvSeasons(showId, existingSeasons = []) { + const url = new URL(`https://api.tvmaze.com/shows/${showId}/episodes`); + const episodes = await fetchJson(url); + const existingBySeason = new Map( + normalizeSeasonData(existingSeasons).map((season) => [season.number, season]), + ); + const grouped = new Map(); + + episodes.forEach((episode) => { + const seasonNumber = Number(episode.season); + if (!Number.isFinite(seasonNumber)) { + return; + } + + const current = grouped.get(seasonNumber) || { + number: seasonNumber, + episodeCount: 0, + runtimeMinutes: 0, + startedAt: existingBySeason.get(seasonNumber)?.startedAt || "", + finishedAt: existingBySeason.get(seasonNumber)?.finishedAt || "", + }; + + current.episodeCount += 1; + current.runtimeMinutes += Number(episode.runtime) || 0; + grouped.set(seasonNumber, current); + }); + + return Array.from(grouped.values()).sort((a, b) => a.number - b.number); +} + +function normalizeSeasonData(seasons) { + if (!Array.isArray(seasons)) { + return []; + } + + return seasons + .map((season) => ({ + number: Number(season.number), + episodeCount: Number(season.episodeCount) || 0, + runtimeMinutes: Number(season.runtimeMinutes) || 0, + startedAt: normalizeDateValue(season.startedAt || season.startDate), + finishedAt: normalizeDateValue(season.finishedAt || season.finishDate || season.completedAt), + })) + .filter((season) => Number.isFinite(season.number)) + .sort((a, b) => a.number - b.number); +} + +function formatRuntime(minutes) { + const totalMinutes = Number(minutes) || 0; + + if (!totalMinutes) { + return "Runtime unknown"; + } + + const hours = Math.floor(totalMinutes / 60); + const remainingMinutes = totalMinutes % 60; + + if (!hours) { + return `${remainingMinutes}m`; + } + + return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +function getItemProgress(item) { + if (normalizeDateValue(item.finishedAt)) { + return { key: "complete", label: mediaTypes[item.type]?.progressLabels.complete || "Finished" }; + } + + if (normalizeDateValue(item.startedAt)) { + return { key: "active", label: mediaTypes[item.type]?.progressLabels.active || "In progress" }; + } + + if (item.type === "tv") { + const seasons = normalizeSeasonData(item.seasons); + const finishedCount = seasons.filter((season) => normalizeDateValue(season.finishedAt)).length; + const startedCount = seasons.filter((season) => normalizeDateValue(season.startedAt)).length; + + if (seasons.length && finishedCount === seasons.length) { + return { key: "complete", label: "All seasons finished" }; + } + + if (finishedCount || startedCount) { + return { key: "active", label: "Season dates started" }; + } + } + + return { key: "planned", label: mediaTypes[item.type]?.progressLabels.planned || "No dates" }; +} + +function getSeasonProgress(season) { + if (normalizeDateValue(season.finishedAt)) { + return { key: "complete", label: "Finished" }; + } + + if (normalizeDateValue(season.startedAt)) { + return { key: "active", label: "Started" }; + } + + return { key: "planned", label: "No dates" }; +} + +function getProgressText(item) { + const progress = getItemProgress(item); + const startedAt = normalizeDateValue(item.startedAt); + const finishedAt = normalizeDateValue(item.finishedAt); + + if (item.type === "tv" && !startedAt && !finishedAt && progress.key !== "planned") { + const seasons = normalizeSeasonData(item.seasons); + const finishedCount = seasons.filter((season) => normalizeDateValue(season.finishedAt)).length; + return `${progress.label} (${finishedCount}/${seasons.length})`; + } + + if (progress.key === "complete") { + return `${progress.label} ${formatShortDate(finishedAt)}`; + } + + if (progress.key === "active") { + return `${progress.label} ${formatShortDate(startedAt)}`; + } + + return progress.label; +} + +function normalizeDateValue(value) { + if (!value) { + return ""; + } + + const text = String(value).trim(); + const dateMatch = text.match(/^\d{4}-\d{2}-\d{2}/); + if (!dateMatch) { + return ""; + } + + const date = new Date(`${dateMatch[0]}T00:00:00`); + return Number.isNaN(date.getTime()) ? "" : dateMatch[0]; +} + +function formatShortDate(value) { + const dateValue = normalizeDateValue(value); + return dateValue ? `on ${formatDate(dateValue)}` : ""; +} + +function compareDates(left, right) { + const leftDate = normalizeDateValue(left) || left || ""; + const rightDate = normalizeDateValue(right) || right || ""; + return new Date(leftDate || 0).getTime() - new Date(rightDate || 0).getTime(); +} + +function getYear(value) { + return value ? String(value).slice(0, 4) : ""; +} + +function getYearFromText(value) { + const match = String(value || "").match(/\b(19|20)\d{2}\b/); + return match ? match[0] : ""; +} + +function stripHtml(value) { + const doc = new DOMParser().parseFromString(value, "text/html"); + return doc.body.textContent || ""; +} + +function normalizeImageUrl(value) { + if (!value) { + return ""; + } + + return value.startsWith("http://") ? value.replace("http://", "https://") : value; +} + +function normalizeTitle(value) { + return String(value || "").trim().toLowerCase().replace(/\s+/g, " "); +} + +function getInitials(title) { + return String(title || "?") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); +} + +function createId() { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttribute(value) { + return escapeHtml(value); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..9bd1f2f --- /dev/null +++ b/index.html @@ -0,0 +1,135 @@ + + + + + + Media Tracker + + + +
+
+
+

Self-hosted collection

+

Media Tracker

+
+
+ + + +
+
+ +
+
+
+
+

Discover

+

Search media

+
+
+ +
+ + + + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
Ready
+
+
+ +
+
+
+

Collection

+

Tracker

+
+
+
+ +
+ + + + +
+ +
+
+
+
+ + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..8a4ba9a --- /dev/null +++ b/server.js @@ -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 "/"; + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..4778650 --- /dev/null +++ b/styles.css @@ -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; + } +}