Initial commit for eventlens

This commit is contained in:
ecki
2026-04-09 19:00:12 +02:00
commit 51aab152ff
21 changed files with 1862 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>eventlens</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="page-shell">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">eventlens</p>
<h1>Konzerte und Events im Blick, bevor sie an dir vorbeiziehen.</h1>
<p class="hero-text">
Beobachte Kuenstler oder Ereignisse fuer Hamburg oder ganz Deutschland,
synchronisiere neue Termine und markiere gekaufte Tickets fuer die
Erinnerungsfunktion.
</p>
<div class="hero-actions">
<button id="sync-button" class="primary-button">Jetzt synchronisieren</button>
<a class="ghost-button" href="/docs" target="_blank" rel="noreferrer">API Docs</a>
</div>
</div>
<aside class="status-panel">
<div class="stat-card">
<span class="stat-label">Watchlist</span>
<strong id="watch-count">0</strong>
</div>
<div class="stat-card">
<span class="stat-label">Gefundene Events</span>
<strong id="event-count">0</strong>
</div>
<div class="stat-card">
<span class="stat-label">Mit Ticket</span>
<strong id="ticket-count">0</strong>
</div>
<div class="stat-card wide">
<span class="stat-label">Letzte Aktion</span>
<strong id="sync-status">Noch kein Sync ausgefuehrt</strong>
</div>
</aside>
</header>
<main class="dashboard-grid">
<section class="panel">
<div class="panel-header">
<div>
<p class="section-kicker">Watchlist</p>
<h2>Neue Beobachtung anlegen</h2>
</div>
</div>
<form id="watch-form" class="watch-form">
<label>
Name
<input id="watch-name" name="name" type="text" placeholder="z. B. Deichkind" required />
</label>
<label>
Typ
<select id="watch-type" name="watch_type">
<option value="artist">Kuenstler</option>
<option value="event">Event</option>
</select>
</label>
<label>
Region
<select id="watch-region" name="region_scope">
<option value="hamburg">Hamburg</option>
<option value="germany">Deutschland</option>
</select>
</label>
<label class="full-width">
Notiz
<textarea id="watch-notes" name="notes" rows="3" placeholder="Optional, z. B. bevorzugte Venue oder Tourname"></textarea>
</label>
<button type="submit" class="primary-button">Zur Watchlist hinzufuegen</button>
</form>
<div id="watch-items" class="watch-list empty-state">
Noch keine Eintraege vorhanden.
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="section-kicker">Events</p>
<h2>Gefundene Termine</h2>
</div>
<div class="panel-tools">
<select id="event-filter">
<option value="all">Alle</option>
<option value="ticketed">Nur mit Ticket</option>
<option value="open">Nur offen</option>
</select>
</div>
</div>
<div id="events" class="event-list empty-state">
Noch keine Events vorhanden.
</div>
</section>
<section class="panel panel-wide">
<div class="panel-header">
<div>
<p class="section-kicker">Benachrichtigungen</p>
<h2>Versandprotokoll</h2>
</div>
</div>
<div id="notifications" class="notification-list empty-state">
Noch keine Benachrichtigungen protokolliert.
</div>
</section>
</main>
</div>
<div id="toast" class="toast" aria-live="polite"></div>
<script src="/static/app.js" defer></script>
</body>
</html>
+338
View File
@@ -0,0 +1,338 @@
const state = {
watchItems: [],
events: [],
notifications: [],
};
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 syncButton = document.querySelector("#sync-button");
const toastEl = document.querySelector("#toast");
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 date = new Date(value);
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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;
}
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 filteredEvents = state.events.filter((event) => {
if (filterValue === "ticketed") {
return event.is_ticket_purchased;
}
if (filterValue === "open") {
return !event.is_ticket_purchased;
}
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 ${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] = await Promise.all([
apiFetch("/watch-items"),
apiFetch("/events"),
apiFetch("/notifications"),
]);
state.watchItems = watchItems;
state.events = events;
state.notifications = notifications;
renderStats();
renderWatchItems();
renderEvents();
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);
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);
});
+439
View File
@@ -0,0 +1,439 @@
:root {
--bg: #f5efe3;
--bg-accent: #efe3cf;
--surface: rgba(255, 252, 246, 0.82);
--surface-strong: #fffaf2;
--line: rgba(46, 39, 30, 0.14);
--text: #1f1a17;
--muted: #65594d;
--primary: #c24d2c;
--primary-dark: #8f381f;
--success: #245e3f;
--warning: #8d5a13;
--danger: #8a2f2f;
--shadow: 0 22px 70px rgba(96, 64, 24, 0.14);
--radius-lg: 28px;
--radius-md: 18px;
--radius-sm: 12px;
--mono: "IBM Plex Mono", monospace;
--sans: "Space Grotesk", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(194, 77, 44, 0.18), transparent 28%),
radial-gradient(circle at 85% 18%, rgba(36, 94, 63, 0.14), transparent 22%),
linear-gradient(135deg, var(--bg), #f7f2ea 48%, var(--bg-accent));
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 32px 32px;
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent 72%);
}
.page-shell {
width: min(1280px, calc(100vw - 32px));
margin: 0 auto;
padding: 32px 0 56px;
}
.hero {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
.hero-copy,
.status-panel,
.panel {
backdrop-filter: blur(12px);
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.hero-copy {
padding: 36px;
border-radius: var(--radius-lg);
}
.eyebrow,
.section-kicker,
.stat-label,
.meta-label {
margin: 0 0 10px;
font-family: var(--mono);
font-size: 0.76rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: clamp(2.5rem, 5vw, 4.9rem);
line-height: 0.95;
max-width: 11ch;
}
.hero-text {
max-width: 58ch;
margin-top: 18px;
font-size: 1.05rem;
line-height: 1.6;
color: var(--muted);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 26px;
}
.status-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 20px;
border-radius: var(--radius-lg);
}
.stat-card {
padding: 18px;
background: rgba(255, 250, 242, 0.86);
border-radius: var(--radius-md);
border: 1px solid rgba(46, 39, 30, 0.08);
}
.stat-card strong {
display: block;
font-size: 2rem;
line-height: 1.1;
}
.stat-card.wide {
grid-column: 1 / -1;
}
.stat-card.wide strong {
font-size: 1rem;
line-height: 1.5;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.panel {
padding: 24px;
border-radius: var(--radius-lg);
}
.panel-wide {
grid-column: 1 / -1;
}
.panel-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.panel-tools {
display: flex;
gap: 10px;
}
.watch-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.full-width {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 8px;
font-size: 0.95rem;
color: var(--muted);
}
input,
select,
textarea,
button {
font: inherit;
}
input,
select,
textarea {
width: 100%;
padding: 14px 16px;
border-radius: var(--radius-sm);
border: 1px solid rgba(46, 39, 30, 0.16);
background: rgba(255, 255, 255, 0.88);
color: var(--text);
transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: rgba(194, 77, 44, 0.68);
box-shadow: 0 0 0 4px rgba(194, 77, 44, 0.12);
}
button,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 18px;
border: 0;
border-radius: 999px;
text-decoration: none;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
}
button:hover,
.ghost-button:hover {
transform: translateY(-1px);
}
.primary-button {
background: linear-gradient(135deg, var(--primary), #d0722e);
color: white;
box-shadow: 0 14px 30px rgba(194, 77, 44, 0.26);
}
.ghost-button {
background: rgba(255, 255, 255, 0.62);
color: var(--text);
border: 1px solid rgba(46, 39, 30, 0.12);
}
.watch-list,
.event-list,
.notification-list {
display: grid;
gap: 14px;
}
.empty-state {
padding: 28px;
border-radius: var(--radius-md);
border: 1px dashed rgba(46, 39, 30, 0.18);
color: var(--muted);
text-align: center;
}
.watch-card,
.event-card,
.notification-card {
padding: 18px;
border-radius: var(--radius-md);
background: var(--surface-strong);
border: 1px solid rgba(46, 39, 30, 0.08);
}
.watch-card,
.event-card {
transform: translateY(6px);
opacity: 0;
animation: riseIn 420ms ease forwards;
}
.watch-header,
.event-header,
.notification-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.pill-row,
.action-row,
.event-actions,
.event-meta,
.notification-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(194, 77, 44, 0.09);
color: var(--primary-dark);
font-family: var(--mono);
font-size: 0.78rem;
}
.pill.success {
background: rgba(36, 94, 63, 0.12);
color: var(--success);
}
.pill.warning {
background: rgba(141, 90, 19, 0.14);
color: var(--warning);
}
.pill.danger {
background: rgba(138, 47, 47, 0.12);
color: var(--danger);
}
.muted {
color: var(--muted);
}
.watch-card p,
.event-card p,
.notification-card p {
margin-top: 12px;
line-height: 1.55;
}
.action-button {
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
background: rgba(46, 39, 30, 0.08);
color: var(--text);
}
.action-button.danger {
background: rgba(138, 47, 47, 0.1);
color: var(--danger);
}
.action-button.success {
background: rgba(36, 94, 63, 0.1);
color: var(--success);
}
.event-meta,
.notification-meta {
margin-top: 14px;
color: var(--muted);
font-size: 0.94rem;
}
.event-link {
color: var(--primary-dark);
text-decoration-thickness: 2px;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
max-width: min(420px, calc(100vw - 40px));
padding: 14px 18px;
border-radius: 16px;
background: rgba(31, 26, 23, 0.92);
color: white;
box-shadow: 0 18px 40px rgba(31, 26, 23, 0.24);
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 980px) {
.hero,
.dashboard-grid {
grid-template-columns: 1fr;
}
.panel-wide {
grid-column: auto;
}
}
@media (max-width: 720px) {
.page-shell {
width: min(100vw - 20px, 1280px);
padding-top: 18px;
}
.hero-copy,
.panel,
.status-panel {
padding: 20px;
}
.watch-form,
.status-panel {
grid-template-columns: 1fr;
}
h1 {
max-width: none;
font-size: clamp(2.3rem, 13vw, 3.6rem);
}
.panel-header,
.watch-header,
.event-header,
.notification-header {
flex-direction: column;
}
}