Initial commit for eventlens
This commit is contained in:
@@ -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("&", "&")
|
||||
.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;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user