From a0173b50894036fe53d809ee93d079d03e85a4ec Mon Sep 17 00:00:00 2001 From: Niko Date: Tue, 30 Jun 2026 08:16:04 +0200 Subject: [PATCH 1/3] Nav --- .env.development | 1 + .env.preview | 1 + src/components/Header.astro | 240 +++++++++++++++++++++++------ src/components/sections/city.astro | 2 +- src/content/pages/faq.mdx | 2 +- src/content/pages/krakow.mdx | 120 --------------- src/content/pages/remote/index.mdx | 2 +- src/content/pages/tickets.mdx | 2 +- src/content/pages/venue.mdx | 94 ++++++++++- src/data/nav.ts | 132 ++++++++++------ src/pages/tips.astro | 15 ++ 11 files changed, 391 insertions(+), 220 deletions(-) delete mode 100644 src/content/pages/krakow.mdx create mode 100644 src/pages/tips.astro diff --git a/.env.development b/.env.development index 5b41cdf93..a19427f93 100644 --- a/.env.development +++ b/.env.development @@ -2,3 +2,4 @@ EP_SESSIONS_API="https://static.europython.eu/programme/ep2026/releases/current/ EP_SPEAKERS_API="https://static.europython.eu/programme/ep2026/releases/current/speakers.json" EP_SCHEDULE_API="https://static.europython.eu/programme/ep2026/releases/current/schedule.json" EP_FAST_BUILD="true" +EP_SHOW_HIDDEN="true" diff --git a/.env.preview b/.env.preview index c00bb0d68..f3d8b1d2d 100644 --- a/.env.preview +++ b/.env.preview @@ -1,3 +1,4 @@ EP_SESSIONS_API="https://static.europython.eu/programme/ep2026/releases/current/sessions.json" EP_SPEAKERS_API="https://static.europython.eu/programme/ep2026/releases/current/speakers.json" EP_SCHEDULE_API="https://static.europython.eu/programme/ep2026/releases/current/schedule.json" +EP_SHOW_HIDDEN="true" diff --git a/src/components/Header.astro b/src/components/Header.astro index 5656db823..139b3753a 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -6,18 +6,38 @@ import ThemeToggle from "@components/ThemeToggle.astro"; import { NAV_MENUS, type NavMenu } from "@data/nav"; import { buildLinkChecker } from "@utils/nav"; +const showHidden = + import.meta.env.EP_SHOW_HIDDEN === "true" || + import.meta.env.PUBLIC_EP_SHOW_HIDDEN === "true"; + const linkExists = await buildLinkChecker(); -// Filter nav menus to only show links that point to existing pages +// Build a set of existing URLs for hidden-page detection +const existingUrls = new Set(); +for (const menu of NAV_MENUS) { + if (menu.url && linkExists(menu.url)) existingUrls.add(menu.url); + for (const section of menu.sections ?? []) { + for (const item of section.items) { + if (linkExists(item.url)) existingUrls.add(item.url); + } + } +} + +// Filter nav menus — in showHidden mode keep everything and mark missing pages const activeMenus = NAV_MENUS.map((menu) => ({ ...menu, sections: menu.sections ?.map((section) => ({ ...section, - items: section.items.filter((item) => linkExists(item.url)), + items: section.items + .filter((item) => showHidden || linkExists(item.url)) + .map((item) => ({ + ...item, + _hidden: !linkExists(item.url), + })), })) - .filter((section) => section.items.length > 0), -})).filter((menu) => !menu.sections || menu.sections.length > 0); + .filter((section) => showHidden || section.items.length > 0 || (!section.label && section.items.length === 0)), +})).filter((menu) => showHidden || !menu.sections || menu.sections.length > 0); --- @@ -133,7 +152,8 @@ nav { position: relative; } -.nav-item > a { +.nav-item > a, +.nav-item > button { display: flex; align-items: center; gap: 0.3em; @@ -150,13 +170,26 @@ nav { transition: color 0.15s, text-decoration-color 0.15s; } +.nav-item > button { + background: none; + border: none; + cursor: pointer; +} + .nav-item > a:hover, +.nav-item > button:hover, .nav-item:hover > a, -.nav-item > a:focus { +.nav-item:hover > button, +.nav-item > a:focus-visible, +.nav-item > button:focus-visible { color: var(--color-accent-themed); text-decoration-color: var(--color-accent-themed); } +.nav-hidden-page { + color: oklch(0.6 0.2 30) !important; +} + .nav-arrow { font-size: 0.55em; opacity: 0.6; @@ -204,12 +237,42 @@ nav { } .nav-dropdown-wide { - min-width: 540px; + position: fixed; + top: 52px; + left: 0; + right: 0; + margin: 0 auto; + max-width: 1200px; + padding-top: 0.25rem; +} + +@media (max-width: 900px) { + .nav-dropdown-wide { + position: static; + top: auto; + left: auto; + right: auto; + margin: 0; + max-width: none; + padding-top: 0; + } +} + +@media (max-width: 900px) { + .nav-dropdown-wide { + position: static; + top: auto; + left: auto; + right: auto; + margin: 0; + max-width: none; + padding: 0; + } } .nav-dropdown-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.5rem; } @@ -256,26 +319,38 @@ nav { .nav-mobile-search { display: none; } .nav-search-btn { + background: none; + border: none; + cursor: pointer; + padding: 0.45rem; + border-radius: 4px; + color: var(--color-text-secondary); + transition: color 0.2s, background 0.2s; display: flex; align-items: center; - gap: 0.45rem; - background: var(--color-surface-medium); - border: 1px solid var(--color-border-strong); - border-radius: 4px; - padding: 0.45rem 0.9rem; + justify-content: center; + flex-shrink: 0; margin-left: auto; - margin-right: 0.5rem; - cursor: pointer; + margin-right: 0.25rem; +} + +.nav-icon-btn { + background: none; + border: none; + padding: 0.45rem; + border-radius: 4px; color: var(--color-text-secondary); - font-family: 'Roboto', sans-serif; - font-size: 1rem; - transition: background 0.15s, color 0.15s; + transition: color 0.2s, background 0.2s; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; } -.nav-search-btn:hover { - background: var(--color-border-strong); +.nav-search-btn:hover, +.nav-icon-btn:hover { color: var(--color-text); + background: var(--color-border); } .nav-cta { @@ -294,6 +369,25 @@ nav { } .nav-cta:hover { opacity: 0.85; } +.nav-cta-secondary { + font-size: 0.82rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-secondary); + border: 1px solid var(--color-border-strong); + border-radius: 2px; + padding: 0.55rem 1.1rem; + text-decoration: none; + display: inline-block; + flex-shrink: 0; + margin-left: 0.25rem; + transition: color 0.15s, border-color 0.15s; +} +.nav-cta-secondary:hover { + color: var(--color-accent-themed); + border-color: var(--color-accent-themed); +} + /* Hamburger toggle - visible on mobile */ .nav-toggle { display: none; @@ -314,6 +408,11 @@ nav { display: flex; flex-direction: column; gap: 4px; + order: 100; + margin-left: 0; + } + + .nav-search-btn { margin-left: auto; } @@ -348,16 +447,13 @@ nav { display: flex; } - .nav-item > a { + .nav-item > a, + .nav-item > button { font-size: 1.1rem; padding: 0.8rem 0; border-bottom: 1px solid var(--color-border); } - .nav-search-btn { - display: none; - } - .nav-mobile-search { display: block; } @@ -381,7 +477,8 @@ nav { text-align: left; } - .nav-cta { + .nav-cta, + .nav-cta-secondary { display: none; } @@ -447,21 +544,23 @@ nav { border-bottom-color: oklch(0 0 0 / 0.1) !important; } -:global(.light) nav a:not(.nav-cta), +:global(.light) nav a:not(.nav-cta):not(.nav-cta-secondary), :global(.light) .nav-item > a, +:global(.light) .nav-item > button, :global(.light) .nav-toggle span, :global(.light) .theme-toggle { color: oklch(0.228 0.038 282.9) !important; /* #1a1a2e */ } -:global(.light) .nav-search-btn { - background: oklch(0 0 0 / 0.04) !important; - border-color: var(--color-scrim-15) !important; - color: var(--color-scrim-50) !important; +:global(.light) .nav-search-btn, +:global(.light) .nav-icon-btn { + color: oklch(0.228 0.038 282.9) !important; /* #1a1a2e */ } -:global(.light) .nav-search-btn:hover { +:global(.light) .nav-search-btn:hover, +:global(.light) .nav-icon-btn:hover { color: oklch(0.228 0.038 282.9) !important; /* #1a1a2e */ + background: oklch(0 0 0 / 0.06) !important; } :global(.light) a.nav-cta { @@ -469,6 +568,15 @@ nav { color: var(--color-white) !important; } +:global(.light) a.nav-cta-secondary { + color: var(--color-scrim-60) !important; + border-color: var(--color-scrim-30) !important; +} +:global(.light) a.nav-cta-secondary:hover { + color: var(--color-accent-themed) !important; + border-color: var(--color-accent-themed) !important; +} + :global(.light) .nav-dropdown-inner { background: oklch(1 0 0 / 0.95) !important; border-color: var(--color-scrim-15) !important; @@ -478,6 +586,10 @@ nav { color: oklch(0.228 0.038 282.9) !important; /* #1a1a2e */ } +:global(.light) .nav-dropdown li a.nav-hidden-page { + color: oklch(0.6 0.2 30) !important; +} + :global(.light) .nav-dropdown li a:hover { background: oklch(0 0 0 / 0.04) !important; } @@ -527,7 +639,7 @@ document.addEventListener("DOMContentLoaded", function() { }); // Dropdown toggle on mobile - navLinksEl.querySelectorAll(".nav-item.has-dropdown > a").forEach(function(link) { + navLinksEl.querySelectorAll(".nav-item.has-dropdown > button").forEach(function(link) { link.addEventListener("click", function(e) { if (window.innerWidth <= 900) { e.preventDefault(); @@ -551,8 +663,8 @@ document.addEventListener("DOMContentLoaded", function() { var isOpen = item.classList.contains("dropdown-open"); navLinksEl.querySelectorAll(".nav-item.dropdown-open").forEach(function(other) { other.classList.remove("dropdown-open"); - var otherLink = other.querySelector(":scope > a"); - if (otherLink) otherLink.setAttribute("aria-expanded", "false"); + var otherTrigger = other.querySelector(":scope > a, :scope > button"); + if (otherTrigger) otherTrigger.setAttribute("aria-expanded", "false"); }); if (!isOpen) { item.classList.add("dropdown-open"); @@ -575,7 +687,7 @@ document.addEventListener("DOMContentLoaded", function() { if (ke.key === "Escape") { var item = ddLink.closest(".nav-item.has-dropdown") as HTMLElement | null; if (item) item.classList.remove("dropdown-open"); - var trigger = item ? item.querySelector(":scope > a") as HTMLElement | null : null; + var trigger = item ? item.querySelector(":scope > a, :scope > button") as HTMLElement | null : null; if (trigger) { trigger.setAttribute("aria-expanded", "false"); trigger.focus(); @@ -689,4 +801,34 @@ document.addEventListener("DOMContentLoaded", function() { } }); }); + + // Keep wide dropdowns open while moving mouse from trigger to dropdown + var ddTimeout = null as any; + var activeDropdown = null as HTMLElement | null; + document.querySelectorAll('.nav-item.has-dropdown').forEach(function(item) { + var itemEl = item as HTMLElement; + itemEl.addEventListener('mouseenter', function() { + // Close previous dropdown immediately + if (activeDropdown && activeDropdown !== itemEl) { + if (ddTimeout) clearTimeout(ddTimeout); + activeDropdown.classList.remove('dropdown-open'); + var prevBtn = activeDropdown.querySelector(':scope > button'); + if (prevBtn) prevBtn.setAttribute('aria-expanded', 'false'); + } + activeDropdown = itemEl; + itemEl.classList.add('dropdown-open'); + var btn = itemEl.querySelector(':scope > button'); + if (btn) btn.setAttribute('aria-expanded', 'true'); + }); + itemEl.addEventListener('mouseleave', function() { + if (ddTimeout) clearTimeout(ddTimeout); + ddTimeout = setTimeout(function() { + itemEl.classList.remove('dropdown-open'); + var btn = itemEl.querySelector(':scope > button'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + if (activeDropdown === itemEl) activeDropdown = null; + }, 250); + }); + }); + diff --git a/src/components/sections/city.astro b/src/components/sections/city.astro index 34388e508..0ffc28a35 100644 --- a/src/components/sections/city.astro +++ b/src/components/sections/city.astro @@ -29,7 +29,7 @@ import Section from "@ui/Section.astro"

- +
diff --git a/src/content/pages/faq.mdx b/src/content/pages/faq.mdx index 34cae62e7..5b2e6186b 100644 --- a/src/content/pages/faq.mdx +++ b/src/content/pages/faq.mdx @@ -44,7 +44,7 @@ subtitle: Frequently Asked Questions about EuroPython The conference will return to **Kraków, Poland**, with the main conference hosted at the [ICE Kraków Congress Centre](https://icekrakow.pl/en). -Need help getting around? Check out [how to navigate Kraków](/venue) and our volunteer-curated [Kraków exploration tips](/krakow). +Need help getting around? Check out [how to navigate Kraków](/venue) and our volunteer-curated [Kraków exploration tips](/tips). diff --git a/src/content/pages/krakow.mdx b/src/content/pages/krakow.mdx deleted file mode 100644 index f670e194e..000000000 --- a/src/content/pages/krakow.mdx +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: About Kraków -subtitle: Everything you need to know about traveling to, getting around, and enjoying the city. ---- - -import { Image } from "astro:assets"; - -# Kraków Tips - -
- -At EuroPython 2026, many participants come with their families and friends. When you're -not at the conference, why not take some time to explore Kraków and its nearby areas? - -Below you'll find tips and recommendations to help you make the most of your stay in -Poland's cultural capital! - -## Fun Facts about Kraków - -- **Former Royal Capital**: Kraków was Poland's capital for over 500 years and home to Polish kings at Wawel Castle -- **UNESCO Heritage**: The entire Old Town was one of the first 12 sites listed as UNESCO World Heritage in 1978 -- **Dragon Legend**: According to legend, a dragon once lived in a cave beneath Wawel Hill - you can still visit the dragon's den today! -- **Europe's Largest Square**: The Main Market Square (Rynek Główny) is one of the largest medieval town squares in Europe at 40,000 m² -- **Underground City**: Beneath the Main Square lies a fascinating underground museum showcasing medieval Kraków -- **Salt Mine Wonder**: The nearby Wieliczka Salt Mine is an underground city of chambers, chapels, and sculptures carved entirely from salt -- **Student City**: With over 180,000 students, Kraków has one of the highest student populations in Europe -- **Trumpet Call**: Every hour, a trumpet signal (Hejnał mariacki) is played from St. Mary's Basilica - it stops abruptly mid-melody, commemorating a 13th-century trumpeter shot while warning the city of an attack - -## Getting Around - -### How to get to Kraków? -- **By plane**: [Kraków John Paul II Airport (KRK)](https://www.krakowairport.pl/en) is well connected to the city center (about 20 minutes by train/bus) -- **By train**: [Polish Railways (PKP)](https://www.pkp.pl/en/) connects Kraków to major European cities. The main station (Kraków Główny) is in the city center -- **From the airport**: Take bus 208, 209, or 252 to the city center, or the train from "Kraków Airport" station - -### Public Transport -In Kraków, public transport is efficient and affordable: -- Extensive tram and bus network operated by [MPK Kraków](https://www.mpk.krakow.pl/en/) -- Purchase tickets via mobile app [Jakdojade](https://jakdojade.pl/krakow) or at kiosks -- 20-minute ticket costs around 4.00 PLN (€0.90) -- Night buses run when trams stop -- The city center is very walkable - many attractions are within walking distance - -### Do I need to rent a car? -- Not necessary in Kraków; the Old Town is pedestrian-friendly and parking is limited -- Public transport and walking are the best ways to explore -- For day trips to nearby destinations (Wieliczka, Zakopane, Auschwitz), organized tours or trains are convenient - -### Weather in July -- Average temperatures: 18-25°C (64-77°F) -- Pack layers - evenings can be cooler -- Occasional summer rain showers - - -### Resources -- [Kraków Official Tourism](https://www.krakow.pl/english/) -- [Kraków Travel Guide](https://www.visitkrakow.com/) -- [Culture.pl - Kraków](https://culture.pl/en/city/krakow) - -### Must-See Attractions -- **Wawel Castle & Cathedral**: Royal residence with stunning architecture and the Crown Treasury -- **Main Market Square**: Europe's largest medieval square, with the iconic Cloth Hall and St. Mary's Basilica -- **St. Mary's Basilica**: Gothic church famous for its stunning altarpiece by Veit Stoss and hourly trumpet call -- **Kazimierz (Old Jewish Quarter)**: Historic district with synagogues, museums, and vibrant nightlife -- **Schindler's Factory Museum**: Poignant WWII museum telling the story of Nazi-occupied Kraków -- **Wawel Dragon's Den**: Cave beneath the castle - visit the legendary dragon and see the fire-breathing dragon statue outside -- **Underground Museum**: Beneath the Main Square, discover medieval Kraków through archaeological excavations - -### Day Trips -- **Wieliczka Salt Mine**: UNESCO World Heritage site - an underground cathedral carved entirely from salt (30 minutes from Kraków) -- **Auschwitz-Birkenau**: Important memorial and museum (1.5 hours from Kraków) -- **Zakopane & Tatra Mountains**: Poland's mountain resort with hiking and stunning views (2 hours from Kraków) -- **Ojców National Park**: Beautiful limestone formations and medieval castle ruins (30 minutes from Kraków) -- **Energylandia**: The bigest amusement park in Europe (half an hour from Kraków) - -### For Culture Lovers -- **National Museum**: Outstanding collection of Polish art -- **Manggha Museum**: Japanese art and technology -- **Cricoteka**: Center for the Documentation of the Art of Tadeusz Kantor -- **Starmach Gallery**: Contemporary art gallery in Kazimierz - -### For Nature Lovers -- **Vistula Boulevards**: Riverside promenade popular for walks, cycling, and evening drinks -- **Planty Park**: Green belt encircling the Old Town - perfect for a peaceful walk -- **Las Wolski (Wolski Forest)**: Large forest area with the Zoo and Camaldolese Monastery -- **Błonia Meadow**: Vast green space for picnics and outdoor activities -- **Kościuszko Mound**: Climb for panoramic views of Kraków and surrounding mountains - -### For Food Lovers -Kraków's food scene is thriving! Try these Polish specialties: -- **Pierogi**: Traditional dumplings (try Pierożek for local favorites) -- **Zapiekanka**: Polish open-faced baguette sandwich (find the best on Plac Nowy in Kazimierz) -- **Obwarzanek**: Kraków's unique twisted bagel - grab one fresh from street vendors -- **Barszcz**: Beetroot soup, a Polish classic -- **Żurek**: Sour rye soup served in a bread bowl -- **Milk bars (Bar mleczny)**: Cheap, traditional Polish cafeteria-style eateries - - -## Practical Tips - -### Money -- Currency: Polish Złoty (PLN) -- Almost all places accept cards, but please carry some cash for smaller vendors -- ATMs are widely available - -### Language -- Polish is the official language -- English is widely spoken in tourist areas, restaurants, and hotels - -### Safety -- Kraków is generally very safe -- Watch out for pickpockets in crowded tourist areas -- Use official taxis or ride-sharing apps (Uber, Bolt) - -### Opening Hours -- Shops: Typically 10:00-20:00 Monday-Saturday, closed on Sunday -- Restaurants: Usually open until 22:00-23:00 -- Museums: Often closed Mondays - ---- diff --git a/src/content/pages/remote/index.mdx b/src/content/pages/remote/index.mdx index c3762e016..19e14e865 100644 --- a/src/content/pages/remote/index.mdx +++ b/src/content/pages/remote/index.mdx @@ -10,7 +10,7 @@ import remote from "./remote.png"; # EuroPython 2026 Remote -As happy as we are about seeing many friends in [Kraków](/krakow) +As happy as we are about seeing many friends in [Kraków](/tips) this year, we are also aware that many of you will not be able to make it in person. That’s why EuroPython 2026 Remote is back. Wherever you are, let's celebrate Python and our community, rejoice at our diversity, diff --git a/src/content/pages/tickets.mdx b/src/content/pages/tickets.mdx index 284d5f58b..f21ac146f 100644 --- a/src/content/pages/tickets.mdx +++ b/src/content/pages/tickets.mdx @@ -15,7 +15,7 @@ Discover the latest Python trends, learn from 180+ expert speakers, network with ## Where and When? -🏛️ Kraków, Poland +🏛️ Kraków, Poland

A week of all things Python:

diff --git a/src/content/pages/venue.mdx b/src/content/pages/venue.mdx index 193ba7998..749a2664e 100644 --- a/src/content/pages/venue.mdx +++ b/src/content/pages/venue.mdx @@ -27,5 +27,95 @@ The ICE Kraków Congress Centre is located in the very heart of Kraków, directl - **Kazimierz (Jewish Quarter):** A short tram ride or 20-minute walk southeast. This historic district is known for its synagogues, Jewish heritage, vibrant nightlife, and trendy restaurants and bars. -## Sprints Venue -To be announced soon! +## Fun Facts about Kraków + +- **Former Royal Capital**: Kraków was Poland's capital for over 500 years and home to Polish kings at Wawel Castle +- **UNESCO Heritage**: The entire Old Town was one of the first 12 sites listed as UNESCO World Heritage in 1978 +- **Dragon Legend**: According to legend, a dragon once lived in a cave beneath Wawel Hill — you can still visit the dragon's den today! +- **Europe's Largest Square**: The Main Market Square (Rynek Główny) is one of the largest medieval town squares in Europe at 40,000 m² +- **Underground City**: Beneath the Main Square lies a fascinating underground museum showcasing medieval Kraków +- **Salt Mine Wonder**: The nearby Wieliczka Salt Mine is an underground city of chambers, chapels, and sculptures carved entirely from salt +- **Student City**: With over 180,000 students, Kraków has one of the highest student populations in Europe +- **Trumpet Call**: Every hour, a trumpet signal (Hejnał mariacki) is played from St. Mary's Basilica — it stops abruptly mid-melody, commemorating a 13th-century trumpeter shot while warning the city of an attack + +## Getting to Kraków + +- **By plane**: [Kraków John Paul II Airport (KRK)](https://www.krakowairport.pl/en) is well connected to the city center — about 20 minutes by train or bus +- **By train**: [Polish Railways (PKP)](https://www.pkp.pl/en/) connects Kraków to major European cities. The main station (Kraków Główny) is in the city center +- **From the airport**: Take bus 208, 209, or 252 to the city center, or the train from "Kraków Airport" station + +## Getting Around + +Kraków's public transport is efficient and affordable: + +- Extensive tram and bus network operated by [MPK Kraków](https://www.mpk.krakow.pl/en/) +- Purchase tickets via mobile app [Jakdojade](https://jakdojade.pl/krakow) or at kiosks +- 20-minute ticket costs around 4.00 PLN (€0.90) +- Night buses run when trams stop +- The city center is very walkable — many attractions are within walking distance + +**Do you need a car?** Not in Kraków; the Old Town is pedestrian-friendly and parking is limited. Public transport and walking are the best ways to explore. For day trips to nearby destinations (Wieliczka, Zakopane, Auschwitz), organized tours or trains are convenient. + +## Weather in July + +Average temperatures: 18–25°C (64–77°F). Pack layers — evenings can be cooler. Occasional summer rain showers. + +## Must-See Attractions + +- **Wawel Castle & Cathedral**: Royal residence with stunning architecture and the Crown Treasury +- **Main Market Square**: Europe's largest medieval square, with the iconic Cloth Hall and St. Mary's Basilica +- **St. Mary's Basilica**: Gothic church famous for its stunning altarpiece by Veit Stoss and hourly trumpet call +- **Kazimierz (Old Jewish Quarter)**: Historic district with synagogues, museums, and vibrant nightlife +- **Schindler's Factory Museum**: Poignant WWII museum telling the story of Nazi-occupied Kraków +- **Wawel Dragon's Den**: Cave beneath the castle — visit the legendary dragon and see the fire-breathing dragon statue outside +- **Underground Museum**: Beneath the Main Square, discover medieval Kraków through archaeological excavations + +## Day Trips + +- **Wieliczka Salt Mine**: UNESCO World Heritage site — an underground cathedral carved entirely from salt (30 minutes from Kraków) +- **Auschwitz-Birkenau**: Important memorial and museum (1.5 hours from Kraków) +- **Zakopane & Tatra Mountains**: Poland's mountain resort with hiking and stunning views (2 hours from Kraków) +- **Ojców National Park**: Beautiful limestone formations and medieval castle ruins (30 minutes from Kraków) +- **Energylandia**: The biggest amusement park in Europe (half an hour from Kraków) + +## For Culture & Nature Lovers + +**Culture:** +- **National Museum**: Outstanding collection of Polish art +- **Manggha Museum**: Japanese art and technology +- **Cricoteka**: Center for the Documentation of the Art of Tadeusz Kantor +- **Starmach Gallery**: Contemporary art gallery in Kazimierz + +**Nature:** +- **Vistula Boulevards**: Riverside promenade popular for walks, cycling, and evening drinks +- **Planty Park**: Green belt encircling the Old Town — perfect for a peaceful walk +- **Las Wolski (Wolski Forest)**: Large forest area with the Zoo and Camaldolese Monastery +- **Błonia Meadow**: Vast green space for picnics and outdoor activities +- **Kościuszko Mound**: Climb for panoramic views of Kraków and surrounding mountains + +## Food & Drink + +Kraków's food scene is thriving! Try these Polish specialties: + +- **Pierogi**: Traditional dumplings (try Pierożek for local favorites) +- **Zapiekanka**: Polish open-faced baguette sandwich (find the best on Plac Nowy in Kazimierz) +- **Obwarzanek**: Kraków's unique twisted bagel — grab one fresh from street vendors +- **Barszcz**: Beetroot soup, a Polish classic +- **Żurek**: Sour rye soup served in a bread bowl +- **Milk bars (Bar mleczny)**: Cheap, traditional Polish cafeteria-style eateries + +## Practical Tips + +**Money:** Polish Złoty (PLN). Almost all places accept cards, but carry some cash for smaller vendors. ATMs are widely available. + +**Language:** Polish is the official language. English is widely spoken in tourist areas, restaurants, and hotels. + +**Safety:** Kraków is generally very safe. Watch out for pickpockets in crowded tourist areas. Use official taxis or ride-sharing apps (Uber, Bolt). + +**Opening Hours:** Shops typically 10:00–20:00 Monday–Saturday, closed on Sunday. Restaurants usually open until 22:00–23:00. Museums often closed Mondays. + +## Resources + +- [Kraków Official Tourism](https://www.krakow.pl/english/) +- [Kraków Travel Guide](https://www.visitkrakow.com/) +- [Culture.pl - Kraków](https://culture.pl/en/city/krakow) diff --git a/src/data/nav.ts b/src/data/nav.ts index 5ae86618b..d033ac48c 100644 --- a/src/data/nav.ts +++ b/src/data/nav.ts @@ -1,13 +1,8 @@ -/** - * Single source of truth for all navigation and footer links. - * - * Inspired by ep26-draft/src/menu.py — typed, structured, reusable. - */ - export interface Link { label: string; url: string; external?: boolean; + _hidden?: boolean; } export interface NavSection { @@ -31,12 +26,13 @@ export interface FooterColumn { const L = { // Programme + schedule: { label: "Schedule", url: "/schedule" }, talks: { label: "Talks", url: "/talks" }, tutorials: { label: "Tutorials", url: "/tutorials" }, posters: { label: "Posters", url: "/posters" }, tracks: { label: "Tracks", url: "/tracks" }, - speakers: { label: "Speakers", url: "/speakers" }, + speakers: { label: "Our Speakers", url: "/speakers" }, sessions: { label: "List of all Sessions", url: "/sessions" }, guidelines: { label: "Speaker Guidelines", url: "/guidelines" }, mentorship: { label: "Speaker Mentorship", url: "/mentorship" }, @@ -51,7 +47,22 @@ const L = { socialEvent: { label: "Social Event", url: "/social-event" }, beginnersDay: { label: "Beginners' Day", url: "/beginners-day" }, speakersDinner: { label: "Speakers' Dinner", url: "/speakers-dinner" }, + cfp: { label: "CFP & Talk Selection", url: "/cfp" }, openSpaces: { label: "Open Spaces", url: "/open-spaces" }, + keynotes: { label: "Keynotes", url: "/keynotes" }, + cAPISummit: { label: "C-API Summit", url: "/c-api-summit" }, + wasmSummit: { label: "WASM Summit", url: "/wasm-summit" }, + pyladies: { label: "PyLadies", url: "/pyladies" }, + euroSciPy: { label: "EuroSciPy", url: "/euroscipy" }, + organisersSummit: { label: "Organisers Summit", url: "/organisers-summit" }, + beginners: { label: "Beginners", url: "/beginners" }, + lightningTalks: { label: "Lightning Talks", url: "/lightning-talks" }, + dataAI: { label: "Data & AI", url: "/data-ai" }, + inclusivity: { label: "Inclusivity", url: "/inclusivity" }, + childcare: { label: "Childcare", url: "/childcare" }, + help: { label: "Help", url: "/help" }, + discord: { label: "Discord", url: "/discord" }, + team: { label: "Our Team", url: "/team" }, // Participate tickets: { label: "Tickets", url: "/tickets" }, @@ -68,7 +79,7 @@ const L = { // Venue venue: { label: "Venue", url: "/venue" }, - krakow: { label: "Kraków", url: "/krakow" }, + krakow: { label: "Tips", url: "/tips" }, hotels: { label: "Hotels", url: "/hotels" }, // Sponsorship @@ -80,7 +91,7 @@ const L = { }, // Community - about: { label: "About Us", url: "/about" }, + about: { label: "About EuroPython", url: "/about" }, eps: { label: "EuroPython Society", url: "https://europython-society.org/", @@ -123,23 +134,34 @@ export const NAV_MENUS: NavMenu[] = [ // Programme — rich multi-column with labelled sections { label: "Programme", - url: "/sessions", + url: "/schedule", wide: true, sections: [ { - label: "Talks & Schedule", + label: "Sessions", items: [ - L.schedule, + L.keynotes, L.talks, L.tutorials, L.posters, - L.tracks, - L.speakers, + L.lightningTalks, + L.openSpaces, + L.dataAI, ], }, { label: "Summits", - items: [L.langSummit, L.rustSummit, L.packagingSummit], + items: [ + L.langSummit, + L.cAPISummit, + L.packagingSummit, + L.rustSummit, + L.wasmSummit, + ], + }, + { + label: "Community", + items: [L.pyladies, L.euroSciPy, L.organisersSummit], }, { label: "Events & Social", @@ -147,65 +169,85 @@ export const NAV_MENUS: NavMenu[] = [ L.sprints, L.socialEvent, L.beginnersDay, - L.speakersDinner, - L.openSpaces, + L.beginners, L.yearsOfEp, ], }, - { - label: "For Speakers", - items: [L.guidelines, L.mentorship], - }, ], }, - // Attend — simple flat list + // Attend — wide dropdown with 4 columns { label: "Attend", url: "/tickets", + wide: true, sections: [ { + label: "Registration", + items: [L.tickets, L.finaid, L.visa, L.hotels], + }, + { + label: "Speakers", items: [ - L.tickets, - L.finaid, - L.visa, - L.volunteering, - L.faq, - L.coc, - L.accessibility, + L.speakers, + L.guidelines, + L.mentorship, + L.cfp, + L.speakersDinner, ], }, + { + label: "Community", + items: [L.volunteering, L.inclusivity, L.accessibility], + }, + { + label: "Support", + items: [L.childcare, L.help, L.faq, L.coc], + }, ], }, - // Venue — simple flat list + // Venue — single link, no dropdown { label: "Venue", url: "/venue", - sections: [{ items: [L.venue, L.krakow, L.hotels] }], }, - // Sponsorship — simple flat list + // Sponsors — single link, no dropdown { - label: "Sponsorship", - url: "/sponsorship/sponsor", - sections: [{ items: [L.ourSponsors, L.sponsorPkg, L.sponsorInfo] }], + label: "Sponsors", + url: "/sponsors", }, - // Community — simple flat list + // About — simple flat list { label: "Community", url: "/about", + wide: true, sections: [ - { items: [L.about, L.eps, L.communityPartners, L.mediaPartners] }, + { + label: "Organization", + items: [ + L.about, + L.team, + L.communityPartners, + L.mediaPartners, + L.sponsorPkg, + L.sponsorInfo, + ], + }, + { + label: "Online", + items: [L.blog, L.eps], + }, + { + items: [], + }, + { + items: [], + }, ], }, - - // Jobs — single link, no dropdown - { - label: "Jobs", - url: "/jobs", - }, ]; // ── Social links ──────────────────────────────────────────── @@ -244,7 +286,7 @@ export const FOOTER_COLUMNS: FooterColumn[] = [ items: [L.tickets, L.krakow, L.visa], }, { - title: "Programme", + title: "Schedule", items: [ L.schedule, L.talks, @@ -270,7 +312,7 @@ export const FOOTER_COLUMNS: FooterColumn[] = [ ], }, { - title: "Sponsorship", + title: "Sponsors", items: [L.ourSponsors, L.sponsorPkg, L.sponsorInfo, L.jobs], }, { diff --git a/src/pages/tips.astro b/src/pages/tips.astro new file mode 100644 index 000000000..2b048397c --- /dev/null +++ b/src/pages/tips.astro @@ -0,0 +1,15 @@ +--- +--- + + + + + + + Redirecting… + + + +

Moved to /venue.

+ + From 3cc7bab3c7d9298b52f7ddaea901c05c7991ca5e Mon Sep 17 00:00:00 2001 From: Niko Date: Tue, 30 Jun 2026 22:29:29 +0200 Subject: [PATCH 2/3] Add components --- src/components/island/SessionFilterBar.svelte | 504 +++++++++++++ src/components/island/SessionGrid.svelte | 271 +++++++ src/components/island/SessionList.svelte | 342 +++++++++ src/components/island/SessionSchedule.svelte | 681 ++++++++++++++++++ src/components/island/SessionView.svelte | 291 ++++++++ src/components/sessions/list-posters.astro | 4 +- src/components/sessions/list-sessions.astro | 7 +- src/pages/session/[slug].astro | 3 +- src/pages/sessions-view.astro | 57 ++ src/pages/sessions.astro | 3 +- src/pages/talks.astro | 16 +- src/pages/tutorials.astro | 16 +- src/stores/sessionFilters.ts | 251 +++++++ src/utils/i18n.ts | 18 + test-results/.last-run.json | 4 + 15 files changed, 2448 insertions(+), 20 deletions(-) create mode 100644 src/components/island/SessionFilterBar.svelte create mode 100644 src/components/island/SessionGrid.svelte create mode 100644 src/components/island/SessionList.svelte create mode 100644 src/components/island/SessionSchedule.svelte create mode 100644 src/components/island/SessionView.svelte create mode 100644 src/pages/sessions-view.astro create mode 100644 src/stores/sessionFilters.ts create mode 100644 src/utils/i18n.ts create mode 100644 test-results/.last-run.json diff --git a/src/components/island/SessionFilterBar.svelte b/src/components/island/SessionFilterBar.svelte new file mode 100644 index 000000000..c861fe604 --- /dev/null +++ b/src/components/island/SessionFilterBar.svelte @@ -0,0 +1,504 @@ + + +
+
+ +
+ + + {#if filters.search} + + {/if} +
+ + +
+ {#if activeCount > 0} + {activeCount} filter{activeCount !== 1 ? 's' : ''} active + + {/if} + +
+
+ + {#if expanded} +
+ +
+ Type +
+ {#each SESSION_TYPES as t} + + {/each} +
+
+ + +
+ Level +
+ {#each LEVELS as l} + + {/each} +
+
+ + +
+ Track +
+ + {#each TRACKS as tr} + + {/each} +
+
+ + + {#if allDays.length > 0} +
+ Day +
+ {#each allDays as d} + + {/each} +
+
+ {/if} + + + {#if allRooms.length > 0} +
+ Room +
+ {#each allRooms as r} + + {/each} +
+
+ {/if} + + +
+ Duration +
+ {#each DURATIONS as d} + + {/each} +
+
+ + +
+ Other +
+ +
+
+
+ {/if} +
+ + diff --git a/src/components/island/SessionGrid.svelte b/src/components/island/SessionGrid.svelte new file mode 100644 index 000000000..3dd1b5ff4 --- /dev/null +++ b/src/components/island/SessionGrid.svelte @@ -0,0 +1,271 @@ + + +
+ {#each sessions as session (session.id || session.data?.code)} +
+
+ + {session.data?.title} + + + {#if session.data?.speakers?.length} +

+ + {#each session.data.speakers as speaker, i} + {speakerName(speaker)}{i < session.data.speakers.length - 1 ? ', ' : ''} + {/each} +

+ {/if} +
+ +
+ {#if session.data?.room} + {session.data.room} + {/if} + {#if session.data?.duration} + {session.data.duration}min + {/if} +
+ +
+ {#if session.data?.session_type} + {capitalize(session.data.session_type)} + {/if} + {#if session.data?.level} + + {capitalize(session.data.level)} + + {/if} + {#if session.data?.track} + {t(session.data.track)} + {/if} +
+
+ {/each} +
+ +{#if sessions.length === 0} +

No sessions found.

+{/if} + + diff --git a/src/components/island/SessionList.svelte b/src/components/island/SessionList.svelte new file mode 100644 index 000000000..8bded220a --- /dev/null +++ b/src/components/island/SessionList.svelte @@ -0,0 +1,342 @@ + + +
+ {#each sessions as session (session.id || session.data?.code)} +
+
+ +
+
+ +
+ + {session.data?.title} + + + {#if session.data?.speakers?.length} +
+ {#each session.data.speakers as speaker, i} + {speakerName(speaker)}{i < session.data.speakers.length - 1 ? ', ' : ''} + {/each} +
+ {/if} + + {#if showAbstract && session.data?.abstract} +

{truncate(session.data.abstract)}

+ {/if} + +
+ {#if session.data?.room} + {session.data.room} + {/if} + {#if session.data?.duration} + {session.data.duration}min + {/if} +
+ +
+ {#if session.data?.session_type} + {capitalize(session.data.session_type)} + {/if} + {#if session.data?.level} + + {capitalize(session.data.level)} + + {/if} + {#if session.data?.track} + {t(session.data.track)} + {/if} +
+
+
+ {/each} +
+ +{#if sessions.length === 0} +

No sessions found.

+{/if} + + diff --git a/src/components/island/SessionSchedule.svelte b/src/components/island/SessionSchedule.svelte new file mode 100644 index 000000000..8d4662de9 --- /dev/null +++ b/src/components/island/SessionSchedule.svelte @@ -0,0 +1,681 @@ + + +{#if !day} +

No schedule data available.

+{:else} +
+

+ {dateText} +

+ +
+ {#if !hasMobile} + +
+
+
Time
+ {#each rooms as room} +
{room}
+ {/each} +
+
+ {/if} + +
+
+ {#each slots as slot} +
+ {#if slot.type !== 'room-change'} +
+

{formatHHmm(slot.start)}

+
+ {/if} + + {#each (sessionsByTime[slot.startTime] ?? []).sort(sortByRoom) as ev} + {#if ev.type === 'room-change'} +
+ {ev.title} +
+ {:else if ev.type === 'break'} +
+ {ev.title} +
+ {:else} +
+
+ + {ev.title} + + {#if ev.speakers?.length} +
+ {#each ev.speakers as speaker, i} + {speaker.name}{i < ev.speakers.length - 1 ? ', ' : ''} + {/each} +
+ {/if} +
+
+ {#if ev.rooms.length === 1} + {ev.rooms[0]} + {/if} + {#if ev.duration} + {ev.duration}min + {/if} +
+
+ {#if ev.level} + + {ev.level.charAt(0).toUpperCase() + ev.level.slice(1)} + + {/if} +
+
+ {/if} + {/each} + + + {#if posters.length && posters[0].startTime === slot.startTime} +
+
+ Posters ({posters[0].rooms.join(', ')}) +
+
+ {#each posters as pst} + + {/each} +
+
+ {/if} +
+ {/each} +
+ +
+ End of the Day — {lastEndTime} +
+
+
+
+{/if} + + diff --git a/src/components/island/SessionView.svelte b/src/components/island/SessionView.svelte new file mode 100644 index 000000000..56188da32 --- /dev/null +++ b/src/components/island/SessionView.svelte @@ -0,0 +1,291 @@ + + +
+ + + + +
+ {#each views as v} + + {/each} + + {#if view === 'list'} + + {/if} +
+ + + {#if view === 'schedule'} + {#if filteredDays.length > 0} + {#each filteredDays as day (day.id)} + + {/each} + {:else if sessions.length > 0} +

+ + Schedule view requires day data. Showing list view instead. +

+ + {:else} +

No schedule data available.

+ {/if} + + + {:else if view === 'list'} + {#if filteredSessions.length > 0} + + {:else if activeFilterCount > 0} +

No sessions match your filters.

+ {:else} +

No sessions to display.

+ {/if} + + + {:else if view === 'grid'} + {#if filteredSessions.length > 0} + + {:else if activeFilterCount > 0} +

No sessions match your filters.

+ {:else} +

No sessions to display.

+ {/if} + {/if} + + + {#if filteredSessions.length > 0} +

+ {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''} + {#if sessions.length !== filteredSessions.length} + (filtered from {sessions.length}) + {/if} + {#if days.length > 0} + · {days.length} day{days.length !== 1 ? 's' : ''} + {/if} +

+ {/if} +
+ + diff --git a/src/components/sessions/list-posters.astro b/src/components/sessions/list-posters.astro index 6f70e22cf..123814a68 100644 --- a/src/components/sessions/list-posters.astro +++ b/src/components/sessions/list-posters.astro @@ -1,6 +1,8 @@ --- import { getCollection } from "astro:content"; +import { t } from "@utils/i18n"; + const allSpeakers = await getCollection("speakers"); const speakerNames: Record = {}; for (const s of allSpeakers) { @@ -52,7 +54,7 @@ const { posters } = Astro.props; {p.data.duration && {p.data.duration} min} {p.data.level && {p.data.level.charAt(0).toUpperCase() + p.data.level.slice(1)}}
- {p.data.track && {p.data.track}} + {p.data.track && {t(p.data.track)}} ))} diff --git a/src/components/sessions/list-sessions.astro b/src/components/sessions/list-sessions.astro index 46f2811d6..69ca3abb6 100644 --- a/src/components/sessions/list-sessions.astro +++ b/src/components/sessions/list-sessions.astro @@ -3,6 +3,7 @@ const { sessions } = Astro.props; import Prose from "@ui/Prose.astro"; import Tag from "@ui/Tag.astro"; import CodeHeart from "@components/island/CodeHeart.svelte"; +import { t } from "@utils/i18n"; import SessionSpeakers from "./session-speakers.astro"; type Session = { @@ -27,7 +28,7 @@ type Session = { sessions.map((session: Session) => (
  • @@ -65,10 +66,10 @@ type Session = { {session.data.track && (
  • Track: - {session.data.track} + {t(session.data.track)}
  • )} diff --git a/src/pages/session/[slug].astro b/src/pages/session/[slug].astro index 4f973c762..cdd0ff560 100644 --- a/src/pages/session/[slug].astro +++ b/src/pages/session/[slug].astro @@ -11,6 +11,7 @@ import Section2 from "@ui/Section2.astro"; // import CodeHeart from "@components/island/CodeHeart.svelte"; import Button from "@ui/Button.astro"; +import { t } from "@utils/i18n"; export async function getStaticPaths() { const sessions = await getCollection("sessions"); @@ -89,7 +90,7 @@ const nextSessionsOrdered = sameRoomNextSession entry.data.track && ( <>
    Track:
    -
    {entry.data.track}
    +
    {t(entry.data.track)}
    ) } diff --git a/src/pages/sessions-view.astro b/src/pages/sessions-view.astro new file mode 100644 index 000000000..f2364f6aa --- /dev/null +++ b/src/pages/sessions-view.astro @@ -0,0 +1,57 @@ +--- +import Layout from "@layouts/Layout.astro"; +import Prose from "@ui/Prose.astro"; +import Title from "@ui/Title.astro"; +import Section2 from "@ui/Section2.astro"; +import SessionView from "@components/island/SessionView.svelte"; +import { getCollection } from "astro:content"; + +// --- sessions --- +const sessions = await getCollection("sessions"); + +// --- speakers map (id → name) --- +const allSpeakers = await getCollection("speakers"); +const speakerMap: Record = {}; +for (const s of allSpeakers) { + speakerMap[s.id] = s.data.name; +} + +// --- days (for schedule view) --- +const days = await getCollection("days"); + +// --- all day IDs (for filter bar) --- +const allDays = days.map((d) => d.id); + +// --- all room names (for filter bar) --- +const allRooms = Array.from( + new Set(days.flatMap((d) => d.data?.rooms ?? [])) +).filter(Boolean).sort(); + +const pageTitle = "Sessions — View Demo"; +const pageDescription = "Interactive session views: schedule, list, and grid"; +--- + + + + Interactive Session Views + +

    + Switch between Schedule (day-by-day timetable), + List (compact list), and Grid (card grid) + views using the tabs below. + This uses SessionView.svelte — a client-side island + component that wraps all three view components. +

    +
    + + +
    +
    diff --git a/src/pages/sessions.astro b/src/pages/sessions.astro index b35c44632..f9068b978 100644 --- a/src/pages/sessions.astro +++ b/src/pages/sessions.astro @@ -6,12 +6,13 @@ import Title from "@ui/Title.astro"; import Section2 from "@ui/Section2.astro"; import Filter from "@components/sessions/filter.astro"; import ListSessions from "@components/sessions/list-sessions.astro"; +import { t } from "@utils/i18n"; const sessions = await getCollection("sessions"); const allTracks = Array.from( new Set(sessions.map((session) => session.data.track).filter((track) => track)) -).sort(); +).sort().map(t); const allTypes = Array.from( new Set(sessions.map((session) => session.data.session_type).filter((type) => type)) diff --git a/src/pages/talks.astro b/src/pages/talks.astro index c6967acd0..bf6265baf 100644 --- a/src/pages/talks.astro +++ b/src/pages/talks.astro @@ -4,6 +4,8 @@ import Section2 from "@ui/Section2.astro"; import Title from "@ui/Title.astro"; import { getCollection } from "astro:content"; +import { t } from "@utils/i18n"; + const allSessions = await getCollection("sessions"); // Pre-resolve all speakers for fast lookup @@ -23,9 +25,9 @@ function speakerHtml(speakers: any): string { } const talks = allSessions .filter((s) => { - const t = s.data.session_type?.toLowerCase(); - const isTalk = t === "talk" || t === "talk (long session)" || t === "panel"; - return isTalk && s.data.track !== "~ None of these topics"; + const st = s.data.session_type?.toLowerCase(); + const isTalk = st === "talk" || st === "talk (long session)" || st === "panel"; + return isTalk && t(s.data.track) !== "General"; }) .sort((a, b) => a.data.title.localeCompare(b.data.title)); @@ -109,7 +111,7 @@ const allDisplayTracks = [...sortedTracks, ...otherTracks]; @@ -119,12 +121,12 @@ const allDisplayTracks = [...sortedTracks, ...otherTracks]; {allDisplayTracks.map((track) => (
    -

    - # {track} +

    + # {t(track)}

    {groups[track].map((talk) => ( -
    +
    {talk.data.title}

    diff --git a/src/pages/tutorials.astro b/src/pages/tutorials.astro index f0da905ca..1f3831f2c 100644 --- a/src/pages/tutorials.astro +++ b/src/pages/tutorials.astro @@ -4,6 +4,8 @@ import Section2 from "@ui/Section2.astro"; import Title from "@ui/Title.astro"; import { getCollection } from "astro:content"; +import { t } from "@utils/i18n"; + const allSessions = await getCollection("sessions"); // Pre-resolve all speakers for fast lookup @@ -76,16 +78,16 @@ const tutorials = allSessions
    - {tutorials.map((t) => ( -
    - {t.data.title} -

    + {tutorials.map((tut) => ( +

    + {tut.data.title} +

    - {t.data.duration} min - {t.data.level && {t.data.level.charAt(0).toUpperCase() + t.data.level.slice(1)}} + {tut.data.duration} min + {tut.data.level && {tut.data.level.charAt(0).toUpperCase() + tut.data.level.slice(1)}}
    - {t.data.track &&

    {t.data.track}

    } + {tut.data.track &&

    {t(tut.data.track)}

    }
    ))}
    diff --git a/src/stores/sessionFilters.ts b/src/stores/sessionFilters.ts new file mode 100644 index 000000000..153f1fe3b --- /dev/null +++ b/src/stores/sessionFilters.ts @@ -0,0 +1,251 @@ +/** + * sessionFilters — persistent filter state for session views. + * + * Uses persistentMap from @nanostores/persistent (same pattern as + * favorites.js) so filter selections survive page reloads. + * + * Each filter dimension is stored as a JSON-encoded value under the + * `ep-filter:` localStorage prefix. + */ + +import { persistentMap } from "@nanostores/persistent"; + +// ── Filter option lists ── + +export const SESSION_TYPES: string[] = [ + "Keynote", + "Talk", + "Tutorial", + "Poster", + "Lightning Talk", + "Open Space", +]; + +export const LEVELS: string[] = ["beginner", "intermediate", "advanced"]; + +export const TRACKS: string[] = [ + "Python Core, Internals, Extensions", + "Machine Learning: Research & Applications", + "Data Engineering and MLOps", + "Jupyter and Scientific Python", + "Data preparation and visualisation", + "Machine Learning, NLP and CV", + "Web Development, Web APIs, Front-End Integration", + "DevOps, Cloud, Scalable Infrastructure", + "Tooling, Packaging, Developer Productivity", + "Testing, Quality Assurance, Security", + "Community Building, Education, Outreach", + "Professional Development, Careers, Leadership", + "Ethics, Social Responsibility, Sustainability, Legal", + "IoT, Embedded Systems, Hardware Integration", + "Python for Games, Art, Play and Expression", + "General", +]; + +/** Tracks that match the "Data & AI" quick filter. */ +export const DATA_AI_TRACKS: string[] = [ + "Machine Learning: Research & Applications", + "Data Engineering and MLOps", + "Jupyter and Scientific Python", + "Data preparation and visualisation", + "Machine Learning, NLP and CV", +]; + +export const DURATIONS = [ + { label: "≤30 min", value: "short", test: (d: number) => d <= 30 }, + { label: "31–60 min", value: "medium", test: (d: number) => d > 30 && d <= 60 }, + { label: "60+ min", value: "long", test: (d: number) => d > 60 }, +]; + +// ── Store ── + +export interface FilterState { + search: string; + types: string[]; // session type names + levels: string[]; // "beginner" | "intermediate" | "advanced" + tracks: string[]; // full track names + days: string[]; // "2026-07-13" etc. + rooms: string[]; + durations: string[]; // "short" | "medium" | "long" + favoritesOnly: boolean; + dataAi: boolean; // Data & AI quick toggle +} + +const defaults: FilterState = { + search: "", + types: [], + levels: [], + tracks: [], + days: [], + rooms: [], + durations: [], + favoritesOnly: false, + dataAi: false, +}; + +export const filterStore = persistentMap>( + "ep-filter:", + {}, + { encode: JSON.stringify, decode: JSON.parse } +); + +// ── Helpers ── + +function readValue(key: string, fallback: T): T { + const raw = filterStore.get()[key]; + if (raw === undefined || raw === null || raw === "") return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function writeValue(key: string, value: unknown) { + const v = JSON.stringify(value); + if (v === JSON.stringify(defaults[key as keyof FilterState])) { + filterStore.setKey(key, ""); + } else { + filterStore.setKey(key, v); + } +} + +/** Read all filter values as a typed object. */ +export function getFilters(): FilterState { + const f: FilterState = { ...defaults }; + for (const k of Object.keys(defaults)) { + (f as any)[k] = readValue(k, (defaults as any)[k]); + } + // dataAi is a quick toggle — when ON, inject DATA_AI_TRACKS + if (f.dataAi) { + f.tracks = [...new Set([...f.tracks, ...DATA_AI_TRACKS])]; + } + return f; +} + +/** Write a single filter value. Empties from store if it matches the default. */ +export function setFilter(key: K, value: FilterState[K]) { + writeValue(key, value); +} + +/** Reset all filters to defaults (clears the localStorage keys). */ +export function clearFilters() { + for (const k of Object.keys(defaults)) { + filterStore.setKey(k, ""); + } +} + +/** + * Filter sessions array by current filter state. + * + * @param sessions — sessions collection entries + * @param speakerMap — { id → name } for search matching + * @param favorites — favorites map from the favorites store + * @param filters — current filter state (optional, reads store if omitted) + * @returns filtered sessions array + */ +export function filterSessions( + sessions: any[], + speakerMap: Record, + favorites: Record, + filters?: FilterState, +): any[] { + const f = filters ?? getFilters(); + const q = f.search.toLowerCase().trim(); + + return sessions.filter((s: any) => { + const d = s.data || s; + + // ── search ── + if (q) { + const title = (d.title ?? "").toLowerCase(); + const speakerNames = (d.speakers ?? []) + .map((sid: any) => { + const id = typeof sid === "string" ? sid : sid.id; + return (speakerMap[id] ?? "").toLowerCase(); + }) + .join(" "); + if (!title.includes(q) && !speakerNames.includes(q)) return false; + } + + // ── session type ── + if (f.types.length > 0) { + const st = (d.session_type ?? "").toLowerCase(); + const match = f.types.some((t) => { + const tt = t.toLowerCase(); + if (tt === "talk") return st === "talk" || st === "talk (long session)" || st === "panel"; + return st === tt || st === tt.toLowerCase(); + }); + if (!match) return false; + } + + // ── level ── + if (f.levels.length > 0) { + if (!f.levels.includes(d.level ?? "")) return false; + } + + // ── track ── + if (f.tracks.length > 0) { + if (!f.tracks.includes(d.track ?? "")) return false; + } + + // ── duration ── + if (f.durations.length > 0) { + const dur = parseInt(d.duration ?? "0", 10); + if (isNaN(dur)) return false; + const ok = f.durations.some((dv) => { + const def = DURATIONS.find((dd) => dd.value === dv); + return def ? def.test(dur) : false; + }); + if (!ok) return false; + } + + // ── room ── + if (f.rooms.length > 0) { + if (!f.rooms.includes(d.room ?? "")) return false; + } + + // ── favorites ── + if (f.favoritesOnly) { + if (!favorites[d.code]) return false; + } + + return true; + }); +} + +/** + * Filter schedule events (from days collection) by current filter state. + * Matches by session_type, level, track against events. + */ +export function filterDayEvents( + events: any[], + filters?: FilterState, +): any[] { + const f = filters ?? getFilters(); + + return events.filter((ev: any) => { + // ── session type ── + if (f.types.length > 0) { + const st = (ev.session_type ?? ev.event_type ?? "").toLowerCase(); + const match = f.types.some((t) => { + const tt = t.toLowerCase(); + if (tt === "talk") return st === "talk" || st === "talk (long session)" || st === "panel"; + return st === tt; + }); + if (!match) return false; + } + + // ── level ── + if (f.levels.length > 0) { + if (!f.levels.includes(ev.level ?? "")) return false; + } + + // ── track ── + if (f.tracks.length > 0) { + if (!f.tracks.includes(ev.track ?? "")) return false; + } + + return true; + }); +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 000000000..0dd478aa3 --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,18 @@ +/** + * i18n — translation map for display strings. + * + * API data may contain internal labels that should be shown differently + * to visitors. Add entries here rather than patching individual components. + */ +const translations: Record = { + "~ None of these topics": "General", +}; + +/** + * Translate a key to its display form. Returns the key unchanged if no + * translation is registered. + */ +export function t(key: string | null | undefined): string { + if (!key) return ""; + return translations[key] || key; +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 000000000..5fca3f84b --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file From b731ee779525eb6ce65f8a9a653e8e6977c2401e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:31:53 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/stores/sessionFilters.ts | 40 ++++++++++++++++++++++-------------- test-results/.last-run.json | 2 +- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/stores/sessionFilters.ts b/src/stores/sessionFilters.ts index 153f1fe3b..c29ec95aa 100644 --- a/src/stores/sessionFilters.ts +++ b/src/stores/sessionFilters.ts @@ -53,7 +53,11 @@ export const DATA_AI_TRACKS: string[] = [ export const DURATIONS = [ { label: "≤30 min", value: "short", test: (d: number) => d <= 30 }, - { label: "31–60 min", value: "medium", test: (d: number) => d > 30 && d <= 60 }, + { + label: "31–60 min", + value: "medium", + test: (d: number) => d > 30 && d <= 60, + }, { label: "60+ min", value: "long", test: (d: number) => d > 60 }, ]; @@ -61,14 +65,14 @@ export const DURATIONS = [ export interface FilterState { search: string; - types: string[]; // session type names - levels: string[]; // "beginner" | "intermediate" | "advanced" - tracks: string[]; // full track names - days: string[]; // "2026-07-13" etc. + types: string[]; // session type names + levels: string[]; // "beginner" | "intermediate" | "advanced" + tracks: string[]; // full track names + days: string[]; // "2026-07-13" etc. rooms: string[]; - durations: string[]; // "short" | "medium" | "long" + durations: string[]; // "short" | "medium" | "long" favoritesOnly: boolean; - dataAi: boolean; // Data & AI quick toggle + dataAi: boolean; // Data & AI quick toggle } const defaults: FilterState = { @@ -124,7 +128,10 @@ export function getFilters(): FilterState { } /** Write a single filter value. Empties from store if it matches the default. */ -export function setFilter(key: K, value: FilterState[K]) { +export function setFilter( + key: K, + value: FilterState[K] +) { writeValue(key, value); } @@ -148,7 +155,7 @@ export function filterSessions( sessions: any[], speakerMap: Record, favorites: Record, - filters?: FilterState, + filters?: FilterState ): any[] { const f = filters ?? getFilters(); const q = f.search.toLowerCase().trim(); @@ -173,7 +180,10 @@ export function filterSessions( const st = (d.session_type ?? "").toLowerCase(); const match = f.types.some((t) => { const tt = t.toLowerCase(); - if (tt === "talk") return st === "talk" || st === "talk (long session)" || st === "panel"; + if (tt === "talk") + return ( + st === "talk" || st === "talk (long session)" || st === "panel" + ); return st === tt || st === tt.toLowerCase(); }); if (!match) return false; @@ -218,10 +228,7 @@ export function filterSessions( * Filter schedule events (from days collection) by current filter state. * Matches by session_type, level, track against events. */ -export function filterDayEvents( - events: any[], - filters?: FilterState, -): any[] { +export function filterDayEvents(events: any[], filters?: FilterState): any[] { const f = filters ?? getFilters(); return events.filter((ev: any) => { @@ -230,7 +237,10 @@ export function filterDayEvents( const st = (ev.session_type ?? ev.event_type ?? "").toLowerCase(); const match = f.types.some((t) => { const tt = t.toLowerCase(); - if (tt === "talk") return st === "talk" || st === "talk (long session)" || st === "panel"; + if (tt === "talk") + return ( + st === "talk" || st === "talk (long session)" || st === "panel" + ); return st === tt; }); if (!match) return false; diff --git a/test-results/.last-run.json b/test-results/.last-run.json index 5fca3f84b..544c11fbc 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,4 +1,4 @@ { "status": "failed", "failedTests": [] -} \ No newline at end of file +}