diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e784943 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy Python Conferences Website + +on: + push: + branches: + - main + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Generate json and ical files + run: python generate_conferences.py + working-directory: website + + - name: Upload site artifact + uses: actions/upload-pages-artifact@v4 + with: + path: website + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index ef238c1..360f034 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ dist/ .~lock.* # data conferences.csv + +website/*.csv +website/conferences.ics +website/conferences.json diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..9424726 --- /dev/null +++ b/website/README.md @@ -0,0 +1,17 @@ + +# Conferences Website + +This website displays a list of Python conferences and allows users to download calendar files. It generates conferences.ics and conferences.json from the CSV files in the repository root. + +## How it works + +A GitHub Actions workflow is set up to run on pushes to the main branch (also by hand and on a schedule). This workflow runs the `website/generate_conferences.py` script to produce the ICS and JSON files. Then all the content in `website/` is deployed to GitHub Pages. + +## How to run locally + +1. Make sure you're in the `website/` directory. +2. And run: + ```sh + python generate_conferences.py && python -m http.server + ``` +3. It is fully static, so you can just go to `http://localhost:8000`. diff --git a/website/app.js b/website/app.js new file mode 100644 index 0000000..49e40de --- /dev/null +++ b/website/app.js @@ -0,0 +1,570 @@ +import { getCountryName, countriesPromise } from "./countries.js"; +import { getStartDate, getEndDate, getToday, formatDate } from "./dates.js"; +import { renderCalendarLinks } from "./calendarLinks.js"; + +let allConferences = []; +let filteredConferences = []; + +function saveFiltersToURL() { + const params = new URLSearchParams(); + + const search = document.getElementById("search-input").value; + const status = document.getElementById("status-filter").value; + const year = document.getElementById("year-filter").value; + const country = document.getElementById("country-filter").value; + const sort = document.getElementById("sort-filter").value; + + if (search) params.set("search", search); + if (status) params.set("status", status); + if (year) params.set("year", year); + if (country) params.set("country", country); + if (sort && sort !== "date-asc") params.set("sort", sort); + + const newURL = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.replaceState({}, "", newURL); +} + +function loadFiltersFromURL() { + const params = new URLSearchParams(window.location.search); + + if (params.has("search")) { + document.getElementById("search-input").value = params.get("search"); + } + if (params.has("status")) { + document.getElementById("status-filter").value = params.get("status"); + } else { + document.getElementById("status-filter").value = "upcoming"; + } + if (params.has("year")) { + document.getElementById("year-filter").value = params.get("year"); + } + if (params.has("country")) { + document.getElementById("country-filter").value = params.get("country"); + } + if (params.has("sort")) { + document.getElementById("sort-filter").value = params.get("sort"); + } +} + +async function loadConferences() { + try { + const response = await fetch("conferences.json"); + if (!response.ok) { + console.error("Failed to load conferences.json", response.status); + return []; + } + const data = await response.json(); + if (!Array.isArray(data)) { + console.error("conferences.json is not an array"); + return []; + } + + const conferences = data.map((row) => ({ + ...row, + year: String(row.year || (row["Start Date"] || "").slice(0, 4) || ""), + })); + + console.log(`Total conferences loaded from JSON: ${conferences.length}`); + + const today = getToday(); + conferences.sort((a, b) => { + const aStart = getStartDate(a); + const bStart = getStartDate(b); + + if (!aStart && !bStart) return 0; + if (!aStart) return 1; + if (!bStart) return -1; + + const aIsUpcoming = aStart >= today; + const bIsUpcoming = bStart >= today; + + if (aIsUpcoming && !bIsUpcoming) return -1; + if (!aIsUpcoming && bIsUpcoming) return 1; + + if (aIsUpcoming && bIsUpcoming) { + return aStart - bStart; + } else { + return bStart - aStart; + } + }); + + return conferences; + } catch (err) { + console.error("Error loading conferences.json", err); + return []; + } +} + +async function init() { + await countriesPromise; + + allConferences = await loadConferences(); + filteredConferences = [...allConferences]; + + await populateFilters(); + updateStats(); + + loadFiltersFromURL(); + applyFilters(); + setupEventListeners(); +} + +async function populateFilters() { + const years = [...new Set(allConferences.map((c) => c.year))] + .filter(Boolean) + .sort() + .reverse(); + const yearSelect = document.getElementById("year-filter"); + years.forEach((year) => { + const option = document.createElement("option"); + option.value = year; + option.textContent = year; + yearSelect.appendChild(option); + }); + + const countries = [ + ...new Set(allConferences.map((c) => c.Country).filter((c) => c)), + ].sort(); + + const countrySelect = document.getElementById("country-filter"); + + for (const country of countries) { + const option = document.createElement("option"); + option.value = country; + option.textContent = await getCountryName(country); + countrySelect.appendChild(option); + } +} + +function updateStats() { + const today = getToday(); + + const upcomingCount = allConferences.filter((c) => { + const start = getStartDate(c); + return start && start >= today; + }).length; + + const countries = new Set( + allConferences.map((c) => c.Country).filter((c) => c), + ); + const years = new Set(allConferences.map((c) => c.year).filter((y) => y)); + const yearsArray = Array.from(years) + .map((y) => parseInt(y, 10)) + .filter((y) => !isNaN(y)); + + document.getElementById("total-events").textContent = allConferences.length; + document.getElementById("total-countries").textContent = countries.size; + document.getElementById("upcoming-events").textContent = upcomingCount; + + if (yearsArray.length > 0) { + document.getElementById("years-span").textContent = + `${Math.min(...yearsArray)}-${Math.max(...yearsArray)}`; + } else { + document.getElementById("years-span").textContent = "N/A"; + } +} + +function applyFilters() { + const searchTerm = document + .getElementById("search-input") + .value.toLowerCase(); + const yearFilter = document.getElementById("year-filter").value; + const countryFilter = document.getElementById("country-filter").value; + const statusFilter = document.getElementById("status-filter").value; + const sortFilter = document.getElementById("sort-filter").value; + const today = getToday(); + + saveFiltersToURL(); + + filteredConferences = allConferences.filter((conf) => { + if (searchTerm) { + const searchableText = [ + conf.Subject, + conf.Location, + conf.Country, + conf.Venue, + ] + .join(" ") + .toLowerCase(); + + if (!searchableText.includes(searchTerm)) return false; + } + + if (yearFilter && conf.year !== yearFilter) return false; + + if (countryFilter && conf.Country !== countryFilter) return false; + + const start = getStartDate(conf); + if (statusFilter === "upcoming") { + if (!start || start < today) return false; + } else if (statusFilter === "past") { + if (!start || start >= today) return false; + } + + return true; + }); + + filteredConferences.sort((a, b) => { + const aStart = getStartDate(a); + const bStart = getStartDate(b); + + switch (sortFilter) { + case "date-asc": { + if (!aStart && !bStart) return 0; + if (!aStart) return 1; + if (!bStart) return -1; + return aStart - bStart; + } + case "date-desc": { + if (!aStart && !bStart) return 0; + if (!aStart) return 1; + if (!bStart) return -1; + return bStart - aStart; + } + case "name-asc": + return (a.Subject || "").localeCompare(b.Subject || ""); + case "name-desc": + return (b.Subject || "").localeCompare(a.Subject || ""); + default: + return 0; + } + }); + + renderConferences(); +} + +async function renderConferences() { + const container = document.getElementById("conferences-container"); + document.getElementById("results-count").textContent = + filteredConferences.length; + + if (filteredConferences.length === 0) { + const searchTerm = document + .getElementById("search-input") + .value.toLowerCase(); + const hasActiveFilters = + document.getElementById("status-filter").value !== "" || + document.getElementById("year-filter").value !== "" || + document.getElementById("country-filter").value !== ""; + + let matchingBeforeFilters = 0; + if (hasActiveFilters && searchTerm) { + matchingBeforeFilters = allConferences.filter((conf) => { + const searchableText = [ + conf.Subject, + conf.Location, + conf.Country, + conf.Venue, + ] + .join(" ") + .toLowerCase(); + return searchableText.includes(searchTerm); + }).length; + } + + if (hasActiveFilters && allConferences.length > 0) { + container.innerHTML = ` +
+ ${ + matchingBeforeFilters > 0 + ? `Found ${matchingBeforeFilters} conference${matchingBeforeFilters !== 1 ? "s" : ""} matching your search, but your filters are hiding them.` + : "Your current filter combination has no matching conferences." + } +
+ +No conferences found
+Try adjusting your filters
++ Discover Python conferences around the world. Find events, submission + deadlines, and sponsorship opportunities. +
+ + +Loading conferences...
+