diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..13f2318 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,480 @@ +const AVAILABLE_YEARS = [2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]; + +const state = { + year: null, + query: "", + status: "all", + country: "all", + sortKey: "startDate", + sortDir: "asc", + rows: [], +}; + +const el = { + subtitle: document.getElementById("subtitle"), + subtitleCount: document.getElementById("subtitle-count"), + notice: document.getElementById("notice"), + error: document.getElementById("error"), + loading: document.getElementById("loading"), + empty: document.getElementById("empty"), + tableWrap: document.getElementById("table-wrap"), + tableBody: document.getElementById("table-body"), + mobileList: document.getElementById("mobile-list"), + yearSelect: document.getElementById("year-select"), + searchInput: document.getElementById("search-input"), + statusSelect: document.getElementById("status-select"), + countrySelect: document.getElementById("country-select"), + sortButtons: Array.from(document.querySelectorAll("button.sort")), +}; + +init(); + +async function init() { + buildYearOptions(); + hydrateStateFromUrl(); + bindEvents(); + await loadYear(state.year); +} + +function buildYearOptions() { + const years = [...AVAILABLE_YEARS].sort((a, b) => b - a); + for (const year of years) { + const option = document.createElement("option"); + option.value = String(year); + option.textContent = String(year); + el.yearSelect.append(option); + } +} + +function hydrateStateFromUrl() { + const params = new URLSearchParams(window.location.search); + const nowYear = new Date().getFullYear(); + const yearFromUrl = Number(params.get("year")); + + if (AVAILABLE_YEARS.includes(yearFromUrl)) { + state.year = yearFromUrl; + } else if (AVAILABLE_YEARS.includes(nowYear)) { + state.year = nowYear; + } else { + state.year = Math.max(...AVAILABLE_YEARS); + } + + state.query = (params.get("q") || "").trim(); + state.status = ["all", "upcoming", "ongoing", "past"].includes(params.get("status")) + ? params.get("status") + : "all"; + state.country = (params.get("country") || "all").trim() || "all"; + state.sortKey = ["subject", "startDate", "endDate", "status", "country"].includes(params.get("sort")) + ? params.get("sort") + : "startDate"; + state.sortDir = params.get("dir") === "desc" ? "desc" : "asc"; + + el.yearSelect.value = String(state.year); + el.searchInput.value = state.query; + el.statusSelect.value = state.status; + setSortIndicators(); +} + +function bindEvents() { + el.yearSelect.addEventListener("change", async (event) => { + state.year = Number(event.target.value); + await loadYear(state.year); + }); + + el.searchInput.addEventListener("input", (event) => { + state.query = event.target.value.trim(); + render(); + syncUrl(); + }); + + el.statusSelect.addEventListener("change", (event) => { + state.status = event.target.value; + render(); + syncUrl(); + }); + + el.countrySelect.addEventListener("change", (event) => { + state.country = event.target.value; + render(); + syncUrl(); + }); + + for (const btn of el.sortButtons) { + btn.addEventListener("click", () => { + const key = btn.dataset.sortKey; + if (state.sortKey === key) { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = key; + state.sortDir = "asc"; + } + setSortIndicators(); + render(); + syncUrl(); + }); + } +} + +async function loadYear(year) { + showLoading(true); + setError(""); + setNotice(""); + + try { + const response = await fetch(`${year}.csv`, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Could not load data for ${year}.`); + } + + const text = await response.text(); + const parsed = parseCsv(text).map(normalizeRow).filter((row) => row.subject); + state.rows = parsed; + + if (!AVAILABLE_YEARS.includes(year)) { + setNotice(`Year ${year} is not in the available year list.`); + } + + populateCountryFilter(state.rows); + render(); + syncUrl(); + } catch (error) { + state.rows = []; + render(); + setError(`${error.message} Available years: ${AVAILABLE_YEARS.join(", ")}.`); + } finally { + showLoading(false); + } +} + +function parseCsv(csvText) { + const rows = []; + let current = ""; + let row = []; + let inQuotes = false; + + for (let i = 0; i < csvText.length; i += 1) { + const char = csvText[i]; + const next = csvText[i + 1]; + + if (char === '"') { + if (inQuotes && next === '"') { + current += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === "," && !inQuotes) { + row.push(current); + current = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && next === "\n") { + i += 1; + } + row.push(current); + if (row.some((cell) => cell.length > 0)) { + rows.push(row); + } + row = []; + current = ""; + continue; + } + + current += char; + } + + if (current.length > 0 || row.length > 0) { + row.push(current); + rows.push(row); + } + + const [header, ...dataRows] = rows; + if (!header) { + return []; + } + + return dataRows.map((cells) => { + const obj = {}; + header.forEach((key, index) => { + obj[key.trim()] = (cells[index] || "").trim(); + }); + return obj; + }); +} + +function normalizeRow(raw) { + const startDate = parseDate(raw["Start Date"]); + const endDate = parseDate(raw["End Date"]) || startDate; + + return { + subject: raw.Subject || "", + startDate, + endDate, + startDateLabel: raw["Start Date"] || "", + endDateLabel: raw["End Date"] || "", + location: raw.Location || "", + country: raw.Country || "", + venue: raw.Venue || "", + websiteUrl: raw["Website URL"] || "", + proposalUrl: raw["Proposal URL"] || "", + sponsorshipUrl: raw["Sponsorship URL"] || "", + status: computeStatus(startDate, endDate), + }; +} + +function parseDate(value) { + if (!value) { + return null; + } + const [year, month, day] = value.split("-").map(Number); + if (!year || !month || !day) { + return null; + } + return new Date(year, month - 1, day, 12, 0, 0, 0); +} + +function computeStatus(startDate, endDate) { + if (!startDate || !endDate) { + return "unknown"; + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0); + + if (today < startDate) { + return "upcoming"; + } + if (today > endDate) { + return "past"; + } + return "ongoing"; +} + +function populateCountryFilter(rows) { + const previous = state.country; + const countries = Array.from(new Set(rows.map((row) => row.country).filter(Boolean))).sort((a, b) => + a.localeCompare(b), + ); + + el.countrySelect.innerHTML = ''; + for (const country of countries) { + const option = document.createElement("option"); + option.value = country; + option.textContent = country; + el.countrySelect.append(option); + } + + state.country = countries.includes(previous) || previous === "all" ? previous : "all"; + el.countrySelect.value = state.country; +} + +function render() { + const filtered = getFilteredRows(state.rows); + const sorted = sortRows(filtered, state.sortKey, state.sortDir); + + el.tableBody.innerHTML = ""; + el.mobileList.innerHTML = ""; + + for (const row of sorted) { + el.tableBody.append(renderRow(row)); + el.mobileList.append(renderMobileCard(row)); + } + + el.subtitleCount.textContent = `${state.year} · ${sorted.length} event${sorted.length === 1 ? "" : "s"}`; + + const hasRows = sorted.length > 0; + el.empty.classList.toggle("hidden", hasRows); + el.tableWrap.classList.toggle("hidden", !hasRows); + el.mobileList.classList.toggle("hidden", !hasRows); +} + +function getFilteredRows(rows) { + const q = state.query.toLowerCase(); + + return rows.filter((row) => { + const matchesStatus = state.status === "all" || row.status === state.status; + const matchesCountry = state.country === "all" || row.country === state.country; + const matchesQuery = + q.length === 0 || + [row.subject, row.location, row.venue, row.country].join(" ").toLowerCase().includes(q); + + return matchesStatus && matchesCountry && matchesQuery; + }); +} + +function sortRows(rows, key, dir) { + const factor = dir === "asc" ? 1 : -1; + + return [...rows].sort((a, b) => { + const av = getSortValue(a, key); + const bv = getSortValue(b, key); + + if (av == null && bv == null) { + return 0; + } + if (av == null) { + return 1; + } + if (bv == null) { + return -1; + } + + if (av instanceof Date && bv instanceof Date) { + return (av.getTime() - bv.getTime()) * factor; + } + + return String(av).localeCompare(String(bv)) * factor; + }); +} + +function getSortValue(row, key) { + switch (key) { + case "subject": + return row.subject; + case "startDate": + return row.startDate; + case "endDate": + return row.endDate; + case "status": + return statusSortOrder(row.status); + case "country": + return row.country; + default: + return row.startDate; + } +} + +function statusSortOrder(status) { + const order = { ongoing: 0, upcoming: 1, past: 2, unknown: 3 }; + return order[status] ?? 99; +} + +function renderRow(row) { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${escapeHtml(row.subject)} + ${escapeHtml(row.startDateLabel)} + ${escapeHtml(row.endDateLabel)} + ${renderStatusBadge(row.status)} + ${escapeHtml(row.country)} + ${escapeHtml(row.location)} + ${escapeHtml(row.venue)} + ${renderLinks(row)} + `; + return tr; +} + +function renderStatusBadge(status) { + const label = status.charAt(0).toUpperCase() + status.slice(1); + const cls = `badge badge-${status}`; + return `${label}`; +} + +function renderMobileCard(row) { + const card = document.createElement("article"); + card.className = "event-card"; + + const dateRange = `${escapeHtml(row.startDateLabel)} → ${escapeHtml(row.endDateLabel)}`; + const locationLine = [row.country, row.location].filter(Boolean).map(escapeHtml).join(" · "); + + card.innerHTML = ` +
+

${escapeHtml(row.subject)}

+ ${renderStatusBadge(row.status)} +
+

Dates${dateRange}

+

Location${locationLine || "—"}

+

Venue${escapeHtml(row.venue) || "—"}

+
Links
${renderLinks(row) || "—"}
+ `; + + return card; +} + +function renderLinks(row) { + const links = [ + ["Website", row.websiteUrl, "link-website"], + ["CFP", row.proposalUrl, "link-cfp"], + ["Sponsor", row.sponsorshipUrl, "link-sponsor"], + ].filter((item) => item[1]); + + if (links.length === 0) { + return ""; + } + + const items = links + .map( + ([label, href, cls]) => + `${label}`, + ) + .join(""); + return ``; +} + +function setSortIndicators() { + for (const btn of el.sortButtons) { + if (btn.dataset.sortKey === state.sortKey) { + btn.dataset.sortDir = state.sortDir; + btn.setAttribute("aria-sort", state.sortDir === "asc" ? "ascending" : "descending"); + } else { + btn.dataset.sortDir = ""; + btn.removeAttribute("aria-sort"); + } + } +} + +function syncUrl() { + const params = new URLSearchParams(); + params.set("year", String(state.year)); + + if (state.query) { + params.set("q", state.query); + } + if (state.status !== "all") { + params.set("status", state.status); + } + if (state.country !== "all") { + params.set("country", state.country); + } + if (state.sortKey !== "startDate") { + params.set("sort", state.sortKey); + } + if (state.sortDir !== "asc") { + params.set("dir", state.sortDir); + } + + const nextUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", nextUrl); +} + +function showLoading(isLoading) { + el.loading.classList.toggle("hidden", !isLoading); +} + +function setNotice(message) { + el.notice.textContent = message; + el.notice.classList.toggle("hidden", !message); +} + +function setError(message) { + el.error.textContent = message; + el.error.classList.toggle("hidden", !message); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttr(value) { + return escapeHtml(value); +} diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..ed2647c --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,354 @@ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;700&display=swap"); + +:root { + --background: #ffffff; + --foreground: #0a0a0a; + --muted: #f5f5f5; + --muted-foreground: #525252; + --border: #e5e5e5; + --input: #e5e5e5; + --ring: #0a0a0a; + --radius: 8px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--background); + color: var(--foreground); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem 3rem; +} + +.header h1 { + margin: 0; + font-family: "Space Grotesk", "Avenir Next", sans-serif; + font-size: clamp(1.75rem, 2vw + 1rem, 2.5rem); + letter-spacing: -0.02em; +} + +.subtitle { + margin: 0.5rem 0 0; + color: var(--muted-foreground); +} + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-top: 1rem; + background: #fff; +} + +.control-grid { + display: grid; + gap: 0.6rem; + grid-template-columns: 130px minmax(220px, 1fr) 120px 110px; + padding: 0.75rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.field span { + font-size: 0.78rem; + color: var(--muted-foreground); +} + +.field-grow { + grid-column: span 1; +} + +input, +select, +button.sort { + border: 1px solid var(--input); + border-radius: 6px; + background: #fff; + color: var(--foreground); + font: inherit; +} + +input, +select { + min-height: 1.95rem; + padding: 0.25rem 0.5rem; + font-size: 0.9rem; +} + +input:focus-visible, +select:focus-visible, +button.sort:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.notice, +.error, +.state { + margin-top: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + color: var(--muted-foreground); +} + +.error { + color: #7f1d1d; + border-color: #fecaca; + background: #fef2f2; +} + +.table-panel { + overflow: hidden; +} + +.table-wrap { + overflow-x: auto; +} + +.mobile-list { + display: none; + padding: 0.6rem; +} + +.event-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.7rem; + margin-bottom: 0.6rem; + background: #fff; +} + +.event-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.event-card h3 { + margin: 0; + font-size: 1rem; + line-height: 1.25; +} + +.event-meta { + margin-top: 0.35rem; + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.event-dates { + font-size: 0.84rem; + color: var(--muted-foreground); + white-space: nowrap; +} + +.event-line { + margin: 0.4rem 0 0; + font-size: 0.86rem; + color: #262626; + display: grid; + grid-template-columns: 70px 1fr; + gap: 0.45rem; + align-items: start; +} + +.event-label { + color: var(--muted-foreground); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + font-weight: 600; +} + +.event-value { + min-width: 0; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 980px; +} + +thead th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + background: var(--muted); + border-bottom: 1px solid var(--border); + padding: 0.6rem 0.75rem; + font-size: 0.85rem; +} + +tbody td { + border-top: 1px solid var(--border); + padding: 0.65rem 0.75rem; + vertical-align: top; + font-size: 0.92rem; +} + +#events-table th:nth-child(2), +#events-table th:nth-child(3), +#events-table td:nth-child(2), +#events-table td:nth-child(3) { + white-space: nowrap; +} + +tbody tr:hover { + background: #fafafa; +} + +button.sort { + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + font-weight: 600; +} + +button.sort::after { + content: ""; + margin-left: 0.3rem; +} + +button.sort[data-sort-dir="asc"]::after { + content: "↑"; +} + +button.sort[data-sort-dir="desc"]::after { + content: "↓"; +} + +.badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid var(--border); +} + +.badge-ongoing { + background: #dcfce7; + color: #166534; + border-color: #86efac; +} + +.badge-upcoming { + background: #dbeafe; + color: #1d4ed8; + border-color: #93c5fd; +} + +.badge-past { + background: #f3f4f6; + color: #4b5563; + border-color: #d1d5db; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.links-vertical { + flex-direction: column; + align-items: flex-start; +} + +.links a { + border: 1px solid var(--border); + padding: 0.1rem 0.4rem; + border-radius: 5px; + font-size: 0.78rem; + text-decoration: none; + color: var(--foreground); +} + +.links a:hover { + filter: brightness(0.98); +} + +.links a.link-website { + background: #eff6ff; + border-color: #bfdbfe; + color: #1d4ed8; +} + +.links a.link-cfp { + background: #ecfeff; + border-color: #a5f3fc; + color: #0e7490; +} + +.links a.link-sponsor { + background: #fff7ed; + border-color: #fed7aa; + color: #c2410c; +} + +.hidden { + display: none; +} + +@media (max-width: 900px) { + .control-grid { + grid-template-columns: 1fr 1fr; + } + + .field-grow { + grid-column: span 2; + } +} + +@media (max-width: 620px) { + .control-grid { + grid-template-columns: 1fr; + } + + .field-grow { + grid-column: span 1; + } +} + +@media (max-width: 768px) { + .table-wrap { + display: none; + } + + .mobile-list { + display: block; + } + + .links-vertical { + flex-direction: row; + flex-wrap: wrap; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + scroll-behavior: auto !important; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..2ad18b5 --- /dev/null +++ b/index.html @@ -0,0 +1,95 @@ + + + + + + Python Conferences + + + + +
+
+
+

Python Conferences

+

+ Browse events by year + + + Want to add a new event? + See repository instructions. + +

+
+
+ +
+
+ + + + + + + +
+
+ + + + +
+
Loading events…
+ + + +
+
+ + + +