446 lines
13 KiB
JavaScript
446 lines
13 KiB
JavaScript
const state = {
|
|
watchItems: [],
|
|
events: [],
|
|
notifications: [],
|
|
providerStatuses: [],
|
|
};
|
|
|
|
const watchItemsEl = document.querySelector("#watch-items");
|
|
const eventsEl = document.querySelector("#events");
|
|
const notificationsEl = document.querySelector("#notifications");
|
|
const watchForm = document.querySelector("#watch-form");
|
|
const eventFilter = document.querySelector("#event-filter");
|
|
const providerFilter = document.querySelector("#provider-filter");
|
|
const syncButton = document.querySelector("#sync-button");
|
|
const toastEl = document.querySelector("#toast");
|
|
const providerSummaryEl = document.querySelector("#provider-summary");
|
|
const providerStatusListEl = document.querySelector("#provider-status-list");
|
|
|
|
function showToast(message) {
|
|
toastEl.textContent = message;
|
|
toastEl.classList.add("visible");
|
|
window.clearTimeout(showToast.timeoutId);
|
|
showToast.timeoutId = window.setTimeout(() => {
|
|
toastEl.classList.remove("visible");
|
|
}, 2600);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "unbekannt";
|
|
}
|
|
|
|
const normalizedValue =
|
|
typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value)
|
|
? `${value}Z`
|
|
: value;
|
|
|
|
const date = new Date(normalizedValue);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(date);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
async function apiFetch(url, options = {}) {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(options.headers || {}),
|
|
},
|
|
...options,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let message = "Die Anfrage ist fehlgeschlagen.";
|
|
try {
|
|
const payload = await response.json();
|
|
message = payload.detail || message;
|
|
} catch (_) {
|
|
message = response.statusText || message;
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function renderStats() {
|
|
document.querySelector("#watch-count").textContent = state.watchItems.length;
|
|
document.querySelector("#event-count").textContent = state.events.length;
|
|
document.querySelector("#ticket-count").textContent = state.events.filter(
|
|
(event) => event.is_ticket_purchased
|
|
).length;
|
|
renderProviderSummary();
|
|
}
|
|
|
|
function prettifyProviderName(value) {
|
|
const names = {
|
|
ticketmaster: "Ticketmaster",
|
|
bandsintown: "Bandsintown",
|
|
eventim: "Eventim",
|
|
barclays_arena: "Barclays Arena",
|
|
fabrik: "Fabrik",
|
|
};
|
|
|
|
return names[value] || value;
|
|
}
|
|
|
|
function getProviderClass(value) {
|
|
const classes = {
|
|
ticketmaster: "provider-ticketmaster",
|
|
bandsintown: "provider-bandsintown",
|
|
eventim: "provider-eventim",
|
|
barclays_arena: "provider-barclays-arena",
|
|
fabrik: "provider-fabrik",
|
|
};
|
|
|
|
return classes[value] || "";
|
|
}
|
|
|
|
function renderProviderSummary() {
|
|
if (!state.events.length) {
|
|
providerSummaryEl.innerHTML = '<span class="provider-chip">Noch keine Daten</span>';
|
|
return;
|
|
}
|
|
|
|
const counts = state.events.reduce((accumulator, event) => {
|
|
const key = event.source || "unknown";
|
|
accumulator[key] = (accumulator[key] || 0) + 1;
|
|
return accumulator;
|
|
}, {});
|
|
|
|
providerSummaryEl.innerHTML = Object.entries(counts)
|
|
.sort((left, right) => right[1] - left[1])
|
|
.map(
|
|
([provider, count]) => `
|
|
<span class="provider-chip ${getProviderClass(provider)}">
|
|
${escapeHtml(prettifyProviderName(provider))}: ${count}
|
|
</span>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderProviderStatuses() {
|
|
if (!state.providerStatuses.length) {
|
|
providerStatusListEl.className = "provider-status-list empty-state";
|
|
providerStatusListEl.textContent = "Noch keine Provider-Statusdaten vorhanden.";
|
|
return;
|
|
}
|
|
|
|
providerStatusListEl.className = "provider-status-list";
|
|
providerStatusListEl.innerHTML = state.providerStatuses
|
|
.map(
|
|
(entry) => `
|
|
<article class="provider-status-card">
|
|
<div class="notification-header">
|
|
<div>
|
|
<h3>${escapeHtml(prettifyProviderName(entry.provider_name))}</h3>
|
|
<div class="pill-row">
|
|
<span class="pill ${
|
|
entry.status === "ok"
|
|
? "success"
|
|
: entry.status === "blocked"
|
|
? "warning"
|
|
: "danger"
|
|
}">${escapeHtml(entry.status)}</span>
|
|
</div>
|
|
</div>
|
|
<span class="muted">${escapeHtml(formatDate(entry.last_checked_at))}</span>
|
|
</div>
|
|
<p>${escapeHtml(entry.message)}</p>
|
|
</article>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderWatchItems() {
|
|
if (!state.watchItems.length) {
|
|
watchItemsEl.className = "watch-list empty-state";
|
|
watchItemsEl.textContent = "Noch keine Eintraege vorhanden.";
|
|
return;
|
|
}
|
|
|
|
watchItemsEl.className = "watch-list";
|
|
watchItemsEl.innerHTML = state.watchItems
|
|
.map(
|
|
(item) => `
|
|
<article class="watch-card">
|
|
<div class="watch-header">
|
|
<div>
|
|
<h3>${escapeHtml(item.name)}</h3>
|
|
<div class="pill-row">
|
|
<span class="pill">${item.watch_type}</span>
|
|
<span class="pill">${item.region_scope}</span>
|
|
<span class="pill ${item.is_active ? "success" : "warning"}">
|
|
${item.is_active ? "aktiv" : "pausiert"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="action-row">
|
|
<button class="action-button" data-action="toggle-watch" data-id="${item.id}">
|
|
${item.is_active ? "Pausieren" : "Aktivieren"}
|
|
</button>
|
|
<button class="action-button danger" data-action="delete-watch" data-id="${item.id}">
|
|
Loeschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p>${escapeHtml(item.notes || "Keine Notiz hinterlegt.")}</p>
|
|
</article>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function getWatchNameById(id) {
|
|
return state.watchItems.find((item) => item.id === id)?.name || `Watch #${id}`;
|
|
}
|
|
|
|
function renderEvents() {
|
|
const filterValue = eventFilter.value;
|
|
const providerValue = providerFilter.value;
|
|
const filteredEvents = state.events.filter((event) => {
|
|
if (filterValue === "ticketed") {
|
|
if (!event.is_ticket_purchased) {
|
|
return false;
|
|
}
|
|
}
|
|
if (filterValue === "open" && event.is_ticket_purchased) {
|
|
return false;
|
|
}
|
|
|
|
if (providerValue !== "all" && event.source !== providerValue) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (!filteredEvents.length) {
|
|
eventsEl.className = "event-list empty-state";
|
|
eventsEl.textContent = "Noch keine Events vorhanden.";
|
|
return;
|
|
}
|
|
|
|
eventsEl.className = "event-list";
|
|
eventsEl.innerHTML = filteredEvents
|
|
.map(
|
|
(event) => `
|
|
<article class="event-card">
|
|
<div class="event-header">
|
|
<div>
|
|
<h3>${escapeHtml(event.title)}</h3>
|
|
<div class="pill-row">
|
|
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
|
|
<span class="pill">${escapeHtml(event.city || "ohne Stadt")}</span>
|
|
<span class="pill ${getProviderClass(event.source)}">
|
|
${escapeHtml(prettifyProviderName(event.source))}
|
|
</span>
|
|
<span class="pill ${event.is_ticket_purchased ? "success" : "warning"}">
|
|
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="event-actions">
|
|
<button
|
|
class="action-button ${event.is_ticket_purchased ? "" : "success"}"
|
|
data-action="toggle-ticket"
|
|
data-id="${event.id}"
|
|
data-value="${event.is_ticket_purchased ? "false" : "true"}"
|
|
>
|
|
${event.is_ticket_purchased ? "Ticket entfernen" : "Ticket gekauft"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="event-meta">
|
|
<span><strong>Datum:</strong> ${escapeHtml(formatDate(event.event_date))}</span>
|
|
<span><strong>Venue:</strong> ${escapeHtml(event.venue_name || "unbekannt")}</span>
|
|
<span><strong>Quelle:</strong> ${escapeHtml(event.source)}</span>
|
|
</div>
|
|
${
|
|
event.ticket_url
|
|
? `<p><a class="event-link" href="${escapeHtml(event.ticket_url)}" target="_blank" rel="noreferrer">Ticketlink oeffnen</a></p>`
|
|
: ""
|
|
}
|
|
</article>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderNotifications() {
|
|
if (!state.notifications.length) {
|
|
notificationsEl.className = "notification-list empty-state";
|
|
notificationsEl.textContent = "Noch keine Benachrichtigungen protokolliert.";
|
|
return;
|
|
}
|
|
|
|
notificationsEl.className = "notification-list";
|
|
notificationsEl.innerHTML = state.notifications
|
|
.slice(0, 12)
|
|
.map(
|
|
(entry) => `
|
|
<article class="notification-card">
|
|
<div class="notification-header">
|
|
<div>
|
|
<h3>${escapeHtml(entry.notification_type)}</h3>
|
|
<div class="pill-row">
|
|
<span class="pill ${
|
|
entry.status === "sent"
|
|
? "success"
|
|
: entry.status === "failed"
|
|
? "danger"
|
|
: "warning"
|
|
}">${escapeHtml(entry.status)}</span>
|
|
</div>
|
|
</div>
|
|
<span class="muted">${escapeHtml(formatDate(entry.created_at))}</span>
|
|
</div>
|
|
<p>${escapeHtml(entry.message)}</p>
|
|
</article>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function updateSyncStatus(message) {
|
|
document.querySelector("#sync-status").textContent = message;
|
|
}
|
|
|
|
async function loadData() {
|
|
const [watchItems, events, notifications, providerStatuses] = await Promise.all([
|
|
apiFetch("/watch-items"),
|
|
apiFetch("/events"),
|
|
apiFetch("/notifications"),
|
|
apiFetch("/provider-statuses"),
|
|
]);
|
|
|
|
state.watchItems = watchItems;
|
|
state.events = events;
|
|
state.notifications = notifications;
|
|
state.providerStatuses = providerStatuses;
|
|
|
|
renderStats();
|
|
renderWatchItems();
|
|
renderEvents();
|
|
renderProviderStatuses();
|
|
renderNotifications();
|
|
}
|
|
|
|
watchForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
|
|
const payload = {
|
|
name: document.querySelector("#watch-name").value.trim(),
|
|
watch_type: document.querySelector("#watch-type").value,
|
|
region_scope: document.querySelector("#watch-region").value,
|
|
notes: document.querySelector("#watch-notes").value.trim() || null,
|
|
};
|
|
|
|
if (!payload.name) {
|
|
showToast("Bitte einen Namen eingeben.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiFetch("/watch-items", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
watchForm.reset();
|
|
document.querySelector("#watch-region").value = "hamburg";
|
|
document.querySelector("#watch-type").value = "artist";
|
|
await loadData();
|
|
showToast("Watchlist-Eintrag angelegt.");
|
|
} catch (error) {
|
|
showToast(error.message);
|
|
}
|
|
});
|
|
|
|
syncButton.addEventListener("click", async () => {
|
|
syncButton.disabled = true;
|
|
syncButton.textContent = "Synchronisiere...";
|
|
|
|
try {
|
|
const result = await apiFetch("/sync", { method: "POST" });
|
|
updateSyncStatus(
|
|
`${result.new_events} neue Events, ${result.updated_events} aktualisiert, ${result.notifications_sent} Benachrichtigungen versendet`
|
|
);
|
|
await loadData();
|
|
showToast("Synchronisation abgeschlossen.");
|
|
} catch (error) {
|
|
showToast(error.message);
|
|
} finally {
|
|
syncButton.disabled = false;
|
|
syncButton.textContent = "Jetzt synchronisieren";
|
|
}
|
|
});
|
|
|
|
eventFilter.addEventListener("change", renderEvents);
|
|
providerFilter.addEventListener("change", renderEvents);
|
|
|
|
document.addEventListener("click", async (event) => {
|
|
const button = event.target.closest("button[data-action]");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
const { action, id, value } = button.dataset;
|
|
|
|
try {
|
|
if (action === "delete-watch") {
|
|
await apiFetch(`/watch-items/${id}`, { method: "DELETE" });
|
|
await loadData();
|
|
showToast("Watchlist-Eintrag geloescht.");
|
|
return;
|
|
}
|
|
|
|
if (action === "toggle-watch") {
|
|
const watchItem = state.watchItems.find((item) => item.id === Number(id));
|
|
await apiFetch(`/watch-items/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ is_active: !watchItem.is_active }),
|
|
});
|
|
await loadData();
|
|
showToast("Watchlist-Status aktualisiert.");
|
|
return;
|
|
}
|
|
|
|
if (action === "toggle-ticket") {
|
|
await apiFetch(`/events/${id}/purchase`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ is_ticket_purchased: value === "true" }),
|
|
});
|
|
await loadData();
|
|
showToast("Ticketstatus aktualisiert.");
|
|
}
|
|
} catch (error) {
|
|
showToast(error.message);
|
|
}
|
|
});
|
|
|
|
loadData().catch((error) => {
|
|
updateSyncStatus("Fehler beim Laden der Daten");
|
|
showToast(error.message);
|
|
});
|