Files
whattoplay/app.js

280 lines
6.6 KiB
JavaScript

const sourcesConfigUrl = "./data/sources.json";
const state = {
allGames: [],
mergedGames: [],
search: "",
sourceFilter: "all",
sortBy: "title",
sources: [],
};
const ui = {
grid: document.getElementById("gamesGrid"),
summary: document.getElementById("summary"),
searchInput: document.getElementById("searchInput"),
sourceFilter: document.getElementById("sourceFilter"),
sortSelect: document.getElementById("sortSelect"),
refreshButton: document.getElementById("refreshButton"),
template: document.getElementById("gameCardTemplate"),
};
const normalizeTitle = (title) =>
title.toLowerCase().replace(/[:\-]/g, " ").replace(/\s+/g, " ").trim();
const toDateValue = (value) => (value ? new Date(value).getTime() : 0);
const mergeGames = (games) => {
const map = new Map();
games.forEach((game) => {
const key = game.canonicalId || normalizeTitle(game.title);
const entry = map.get(key) || {
title: game.title,
canonicalId: key,
platforms: new Set(),
sources: [],
tags: new Set(),
lastPlayed: null,
playtimeHours: 0,
};
entry.platforms.add(game.platform);
game.tags?.forEach((tag) => entry.tags.add(tag));
entry.sources.push({
name: game.source,
id: game.id,
url: game.url,
platform: game.platform,
});
if (
game.lastPlayed &&
(!entry.lastPlayed || game.lastPlayed > entry.lastPlayed)
) {
entry.lastPlayed = game.lastPlayed;
}
if (Number.isFinite(game.playtimeHours)) {
entry.playtimeHours += game.playtimeHours;
}
map.set(key, entry);
});
return Array.from(map.values()).map((entry) => ({
...entry,
platforms: Array.from(entry.platforms),
tags: Array.from(entry.tags),
}));
};
const sortGames = (games, sortBy) => {
const sorted = [...games];
sorted.sort((a, b) => {
if (sortBy === "lastPlayed") {
return toDateValue(b.lastPlayed) - toDateValue(a.lastPlayed);
}
if (sortBy === "platforms") {
return b.platforms.length - a.platforms.length;
}
return a.title.localeCompare(b.title, "de");
});
return sorted;
};
const filterGames = () => {
const query = state.search.trim().toLowerCase();
let filtered = [...state.mergedGames];
if (state.sourceFilter !== "all") {
filtered = filtered.filter((game) =>
game.sources.some((source) => source.name === state.sourceFilter),
);
}
if (query) {
filtered = filtered.filter((game) => {
const haystack = [
game.title,
...game.platforms,
...game.tags,
...game.sources.map((source) => source.name),
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}
return sortGames(filtered, state.sortBy);
};
const renderSummary = (games) => {
const totalGames = state.mergedGames.length;
const totalSources = state.sources.length;
const duplicates = state.allGames.length - state.mergedGames.length;
const totalPlaytime = state.allGames.reduce(
(sum, game) => sum + (game.playtimeHours || 0),
0,
);
ui.summary.innerHTML = [
{
label: "Konsolidierte Spiele",
value: totalGames,
},
{
label: "Quellen",
value: totalSources,
},
{
label: "Zusammengeführte Duplikate",
value: Math.max(duplicates, 0),
},
{
label: "Gesamte Spielzeit (h)",
value: totalPlaytime.toFixed(1),
},
]
.map(
(item) => `
<div class="summary-card">
<h3>${item.label}</h3>
<p>${item.value}</p>
</div>
`,
)
.join("");
};
const renderGames = (games) => {
ui.grid.innerHTML = "";
games.forEach((game) => {
const card = ui.template.content.cloneNode(true);
card.querySelector(".title").textContent = game.title;
card.querySelector(".badge").textContent =
`${game.platforms.length} Plattformen`;
card.querySelector(".meta").textContent = game.lastPlayed
? `Zuletzt gespielt: ${new Date(game.lastPlayed).toLocaleDateString("de")}`
: "Noch nicht gespielt";
const tagList = card.querySelector(".tag-list");
game.tags.slice(0, 4).forEach((tag) => {
const span = document.createElement("span");
span.className = "tag";
span.textContent = tag;
tagList.appendChild(span);
});
if (!game.tags.length) {
const span = document.createElement("span");
span.className = "tag";
span.textContent = "Ohne Tags";
tagList.appendChild(span);
}
const sources = card.querySelector(".sources");
game.sources.forEach((source) => {
const item = document.createElement("div");
item.className = "source-item";
const name = document.createElement("span");
name.textContent = source.name;
const details = document.createElement("p");
details.textContent = `${source.platform} · ${source.id}`;
item.append(name, details);
sources.appendChild(item);
});
ui.grid.appendChild(card);
});
};
const populateSourceFilter = () => {
ui.sourceFilter.innerHTML = '<option value="all">Alle Quellen</option>';
state.sources.forEach((source) => {
const option = document.createElement("option");
option.value = source.name;
option.textContent = source.label;
ui.sourceFilter.appendChild(option);
});
};
const updateUI = () => {
const filtered = filterGames();
renderSummary(filtered);
renderGames(filtered);
};
const loadSources = async () => {
const response = await fetch(sourcesConfigUrl);
if (!response.ok) {
throw new Error("Konnte sources.json nicht laden.");
}
const config = await response.json();
state.sources = config.sources;
const data = await Promise.all(
config.sources.map(async (source) => {
const sourceResponse = await fetch(source.file);
if (!sourceResponse.ok) {
throw new Error(`Konnte ${source.file} nicht laden.`);
}
const list = await sourceResponse.json();
return list.map((game) => ({
...game,
source: source.name,
platform: game.platform || source.platform,
}));
}),
);
state.allGames = data.flat();
state.mergedGames = mergeGames(state.allGames);
};
const attachEvents = () => {
ui.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
updateUI();
});
ui.sourceFilter.addEventListener("change", (event) => {
state.sourceFilter = event.target.value;
updateUI();
});
ui.sortSelect.addEventListener("change", (event) => {
state.sortBy = event.target.value;
updateUI();
});
ui.refreshButton.addEventListener("click", async () => {
ui.refreshButton.disabled = true;
ui.refreshButton.textContent = "Lade ...";
try {
await loadSources();
populateSourceFilter();
updateUI();
} finally {
ui.refreshButton.disabled = false;
ui.refreshButton.textContent = "Daten neu laden";
}
});
};
const init = async () => {
try {
await loadSources();
populateSourceFilter();
attachEvents();
updateUI();
} catch (error) {
ui.grid.innerHTML = `<div class="card">${error.message}</div>`;
}
};
init();