1035 lines
31 KiB
JavaScript
1035 lines
31 KiB
JavaScript
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
|
|
? `<a class="link-action" href="${escapeAttribute(item.externalUrl)}" target="_blank" rel="noreferrer">Source</a>`
|
|
: "";
|
|
|
|
return `
|
|
<article class="result-card">
|
|
${renderCover(item.cover, item.title)}
|
|
<div class="item-body">
|
|
<div class="item-title-row">
|
|
<div>
|
|
<h3 class="item-title">${escapeHtml(item.title)}</h3>
|
|
<p class="item-meta">${escapeHtml(formatMeta(item))}</p>
|
|
</div>
|
|
<span class="badge ${type.badge}">${type.singular}</span>
|
|
</div>
|
|
${item.description ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ""}
|
|
<div class="result-actions">
|
|
<button class="button primary" type="button" data-add-result="${index}" ${alreadyAdded ? "disabled" : ""}>
|
|
${alreadyAdded ? "Added" : "Add"}
|
|
</button>
|
|
${sourceLink}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderCollection() {
|
|
renderSummary();
|
|
|
|
const items = getFilteredCollection();
|
|
|
|
if (!items.length) {
|
|
elements.collection.innerHTML = `
|
|
<div class="empty-state">
|
|
<h3>No tracked items</h3>
|
|
<p>Your collection will appear here.</p>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<div class="summary-pill"><strong>${totals.total}</strong><span>Total</span></div>
|
|
<div class="summary-pill"><strong>${totals.active}</strong><span>Started</span></div>
|
|
<div class="summary-pill"><strong>${totals.complete}</strong><span>Finished</span></div>
|
|
`;
|
|
}
|
|
|
|
function renderCollectionCard(item) {
|
|
const type = mediaTypes[item.type];
|
|
const progress = getItemProgress(item);
|
|
const sourceLink = item.externalUrl
|
|
? `<a class="link-action" href="${escapeAttribute(item.externalUrl)}" target="_blank" rel="noreferrer">Source</a>`
|
|
: "";
|
|
|
|
return `
|
|
<article class="collection-card progress-${escapeAttribute(progress.key)}" data-item-id="${escapeAttribute(item.id)}">
|
|
${renderCover(item.cover, item.title)}
|
|
<div class="item-body">
|
|
<div class="item-title-row">
|
|
<div>
|
|
<h3 class="item-title">${escapeHtml(item.title)}</h3>
|
|
<p class="item-meta">${escapeHtml(formatMeta(item))}</p>
|
|
<p class="progress-line">${escapeHtml(getProgressText(item))}</p>
|
|
</div>
|
|
<span class="badge ${type.badge}">${type.singular}</span>
|
|
</div>
|
|
${item.description ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ""}
|
|
<p class="date-line">Added ${formatDate(item.addedAt)}</p>
|
|
${renderItemDateFields(item)}
|
|
<div class="collection-actions">
|
|
<input class="notes-input" data-notes-input type="text" value="${escapeAttribute(item.notes || "")}" placeholder="Notes">
|
|
${sourceLink}
|
|
<button class="button subtle remove-button" type="button" data-remove-item>Remove</button>
|
|
</div>
|
|
</div>
|
|
${renderSeasonTracker(item)}
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderItemDateFields(item) {
|
|
return `
|
|
<div class="date-grid">
|
|
<label>
|
|
Start date
|
|
<input type="date" data-date-field="startedAt" value="${escapeAttribute(normalizeDateValue(item.startedAt))}">
|
|
</label>
|
|
<label>
|
|
Finish date
|
|
<input type="date" data-date-field="finishedAt" value="${escapeAttribute(normalizeDateValue(item.finishedAt))}">
|
|
</label>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSeasonTracker(item) {
|
|
if (item.type !== "tv") {
|
|
return "";
|
|
}
|
|
|
|
const seasons = normalizeSeasonData(item.seasons);
|
|
|
|
if (!seasons.length) {
|
|
const canLoadSeasons = item.source === "tvmaze" && item.sourceId;
|
|
return `
|
|
<div class="season-panel">
|
|
<p class="season-empty">${canLoadSeasons ? "Season data not loaded yet." : "Season tracking needs a TVMaze result."}</p>
|
|
${canLoadSeasons ? '<button class="button subtle season-load-button" type="button" data-load-seasons>Load seasons</button>' : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const finishedCount = seasons.filter((season) => getSeasonProgress(season).key === "complete").length;
|
|
const totalRuntime = seasons.reduce((sum, season) => sum + (season.runtimeMinutes || 0), 0);
|
|
|
|
return `
|
|
<div class="season-panel">
|
|
<div class="season-summary">
|
|
<span>${finishedCount}/${seasons.length} seasons finished</span>
|
|
<span>${formatRuntime(totalRuntime)}</span>
|
|
</div>
|
|
<div class="season-list">
|
|
${seasons.map(renderSeasonRow).join("")}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="season-row progress-${escapeAttribute(progress.key)}">
|
|
<div class="season-title">
|
|
<span class="season-name">${escapeHtml(seasonLabel)}</span>
|
|
<span class="season-meta">${escapeHtml(episodeLabel)} / ${escapeHtml(formatRuntime(season.runtimeMinutes))}</span>
|
|
</div>
|
|
<label class="season-date-field">
|
|
Start
|
|
<input type="date" data-season-date-field="startedAt" data-season-number="${escapeAttribute(season.number)}" value="${escapeAttribute(normalizeDateValue(season.startedAt))}">
|
|
</label>
|
|
<label class="season-date-field">
|
|
Finish
|
|
<input type="date" data-season-date-field="finishedAt" data-season-number="${escapeAttribute(season.number)}" value="${escapeAttribute(normalizeDateValue(season.finishedAt))}">
|
|
</label>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderCover(cover, title) {
|
|
if (cover) {
|
|
return `<img class="cover" src="${escapeAttribute(cover)}" alt="${escapeAttribute(title)} cover" loading="lazy" data-fallback-initials="${escapeAttribute(getInitials(title))}">`;
|
|
}
|
|
|
|
return `<div class="cover-placeholder">${escapeHtml(getInitials(title))}</div>`;
|
|
}
|
|
|
|
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);
|
|
}
|