280 lines
6.6 KiB
JavaScript
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();
|