diff --git a/src/components/FeatureCardGrid.astro b/src/components/FeatureCardGrid.astro new file mode 100644 index 000000000..9fe223459 --- /dev/null +++ b/src/components/FeatureCardGrid.astro @@ -0,0 +1,95 @@ +--- +interface Props { + items: { + title: string; + description?: string; + href?: string; + highlight?: boolean; + }[]; + columns?: number; +} + +const { items, columns = 4 } = Astro.props; +--- + +
+ {items.map((item) => + item.href ? ( + + {item.title} + {item.description && {item.description}} + + ) : ( +
+ {item.title} + {item.description && {item.description}} +
+ ) + )} +
+ + diff --git a/src/components/ScheduleRow.astro b/src/components/ScheduleRow.astro new file mode 100644 index 000000000..223b6ecfa --- /dev/null +++ b/src/components/ScheduleRow.astro @@ -0,0 +1,318 @@ +--- +import { getCollection } from "astro:content"; +import Button from "@ui/Button.astro"; +import CodeHeart from "@components/island/CodeHeart.svelte"; + +export interface Props { + session: any; + parallelCodes?: string[]; + timeLabel?: string; + highlight?: string; + showViewLink?: boolean; + speakerNames?: Record; +} + +const { session: entry, parallelCodes, timeLabel, highlight, showViewLink = true, speakerNames: speakerNamesProp } = Astro.props; + +// Load data +const sessions = await getCollection("sessions"); +const allDays = await getCollection("days"); + +// Use passed speaker names or load them +const allSpeakers = speakerNamesProp ? null : await getCollection("speakers"); +const speakerNames = speakerNamesProp || Object.fromEntries( + (allSpeakers || []).map((s: any) => [s.id, s.data.name]) +); + +// Room order matching the schedule +const ROOM_ORDER = [ + "S1", "S2", "S3A", "S3B", "S4", "S4A", "S4B", + "Glass room", "2.017/2.018", "Fishbowl", "Exhibit Hall", +]; + +const getRoomSortKey = (room: string) => { + const match = room.match(/\((S\d+[A-Z]?)\)/i); + if (!match) { + const idx = ROOM_ORDER.indexOf(room); + return idx === -1 ? `99-${room}` : String(idx).padStart(2, "0"); + } + const idx = ROOM_ORDER.indexOf(match[1]); + return idx === -1 ? `99-${room}` : String(idx).padStart(2, "0"); +}; + +// Find the schedule day +const sessionDate = entry.data.start ? entry.data.start.slice(0, 10) : null; +const currentDay = sessionDate ? allDays.find((d: any) => d.id === sessionDate) : null; + +// Ordered rooms for this day +const dayRooms = (currentDay?.data?.rooms ?? []) + .filter((r: string) => r.toLowerCase() !== "exhibit hall") + .sort((a: string, b: string) => getRoomSortKey(a).localeCompare(getRoomSortKey(b))); + +// Resolve parallel sessions +const parallelSessionCodes: string[] = parallelCodes ?? entry.data.sessions_in_parallel ?? []; +const parallelSessions = parallelSessionCodes + .map((code) => sessions.find((s: any) => s.data.code === code)) + .filter(Boolean) + .filter((s: any) => s?.data?.room !== "Exhibit Hall"); + +// Build room → session map +const sessionByRoom = new Map(); +sessionByRoom.set(entry.data.room, entry); +for (const s of parallelSessions as any[]) { + if (s.data.room) sessionByRoom.set(s.data.room, s); +} + +// Build row entries +const rowEntries = dayRooms.map((room: string) => { + const session = sessionByRoom.get(room) ?? null; + return { + room, + session, + isCurrent: session?.id === (highlight || entry.id), + }; +}); + +const rowTime = timeLabel || (entry.data.start + ? new Date(entry.data.start).toLocaleDateString("en-GB", { timeZone: "Europe/Warsaw", weekday: "short", day: "numeric", month: "short" }) + " \u00b7 " + + new Date(entry.data.start).toLocaleTimeString("en-GB", { timeZone: "Europe/Warsaw", hour: "2-digit", minute: "2-digit" }) + : ""); +--- + +
+ {rowTime &&
{rowTime}
} + +
+ {rowEntries.map(({ room, session, isCurrent }) => + session ? ( +
+ {isCurrent &&
You are here
} + + {session.data.title} + {session.data.speakers?.length > 0 && ( +
+ {session.data.speakers.map((sp: any, idx: number) => { + const id = typeof sp === "string" ? sp : (sp.id || sp.code || sp.slug || ""); + const name = speakerNames[id] || (typeof sp === "object" ? sp.name : "") || id; + return ( + <> + {idx > 0 && , } + {name} + + ); + })} +
+ )} +
+ {room && {room}} + {room && session.data.duration && ·} + {session.data.duration && {session.data.duration}min} +
+ {session.data.level && ( + + {session.data.level.charAt(0).toUpperCase() + session.data.level.slice(1)} + + )} +
+ ) : ( +
{room}
+ ) + )} +
+ + {showViewLink && ( + + )} +
+ + diff --git a/src/components/SessionCard.astro b/src/components/SessionCard.astro new file mode 100644 index 000000000..879eaef55 --- /dev/null +++ b/src/components/SessionCard.astro @@ -0,0 +1,179 @@ +--- +export interface Props { + title: string; + slug: string; + speakers?: string; + duration: string; + level?: string; + track?: string; + searchText?: string; + highlight?: boolean; +} + +const { title, slug, speakers, duration, level, track, searchText, highlight = false } = Astro.props; +--- +
+ {highlight && Current session} + + {highlight ? ( + {title} + ) : ( + {title} + )} + + {speakers &&

} + +

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

{track}

} +
+ + diff --git a/src/content.config.ts b/src/content.config.ts index e3911cc75..5ef78fe64 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -15,6 +15,10 @@ const pages = defineCollection({ subtitle: z.string(), toc: z.boolean().optional().default(true), full: z.boolean().optional().default(false), + box1: z.string().optional(), + box2: z.string().optional(), + box3: z.string().optional(), + box4: z.string().optional(), }), }); diff --git a/src/content/pages/posters.md b/src/content/pages/posters.md new file mode 100644 index 000000000..4ec068ba4 --- /dev/null +++ b/src/content/pages/posters.md @@ -0,0 +1,19 @@ +--- +title: Posters +subtitle: + A visual showcase of projects, research, and ideas — presented in person + during a dedicated poster session +box1: + "Visual format — Posters present a project, library, or research finding as a + visual display. Think of it as a paper you can walk up to and discuss." +box2: + "One-on-one conversations — Unlike talks, poster sessions let you have a + direct conversation with the author. Ask questions, give feedback, and dive as + deep as you like." +box3: + "Great for first-time speakers — Posters are an excellent way to share your + work without the pressure of a stage presentation. Many speakers start here." +box4: + "Dedicated session time — A block of time is reserved in the schedule for + poster viewing, so you won't have to choose between posters and talks." +--- diff --git a/src/content/pages/talks.md b/src/content/pages/talks.md new file mode 100644 index 000000000..ef8c637e8 --- /dev/null +++ b/src/content/pages/talks.md @@ -0,0 +1,18 @@ +--- +title: Talks +subtitle: + The heart of EuroPython — three days of talks across five parallel tracks +box1: + "30-minute talks — Our most common format. Speakers have 30 minutes including + Q&A to share a focused idea, project, or lesson learned." +box2: + "45-minute deep dives — Selected talks get an extended slot for more complex + topics that benefit from extra depth and live demonstrations." +box3: + "For every level — From beginner-friendly introductions to advanced internals + — each talk is tagged by experience level so you can plan your day." +box4: + "Community-selected — Talks are chosen through an open call for proposals and + community voting, ensuring the programme reflects what Pythonistas actually + want to learn." +--- diff --git a/src/content/pages/tutorials.md b/src/content/pages/tutorials.md new file mode 100644 index 000000000..cd3d9e337 --- /dev/null +++ b/src/content/pages/tutorials.md @@ -0,0 +1,18 @@ +--- +title: Tutorials +subtitle: Full-day, hands-on workshops led by domain experts +box1: + "3-hour or 6-hour sessions — Tutorials run for half a day or a full day. Each + one is a self-contained workshop with exercises, examples, and hands-on + practice." +box2: + "Small groups, personal attention — Unlike talks, tutorials are capped in + size. You get direct access to the instructor and can ask questions as you go." +box3: + "Wide range of topics — From Python basics and web frameworks to data science + pipelines, async programming, and testing strategies — there is a tutorial for + every interest." +box4: + "Separate ticket required — Tutorial participation requires a separate + tutorial ticket in addition to your conference pass." +--- diff --git a/src/content/programme/talks/header.md b/src/content/programme/talks/header.md index b4ab44eb9..a615288e2 100644 --- a/src/content/programme/talks/header.md +++ b/src/content/programme/talks/header.md @@ -8,7 +8,7 @@ meta: - text: "5 parallel tracks" cta: text: "Browse all talks" - url: "/talks" + url: "/schedule/talks" advantages: - title: "30-minute talks" description: diff --git a/src/content/programme/tutorials/header.md b/src/content/programme/tutorials/header.md index d056b670f..7da092e84 100644 --- a/src/content/programme/tutorials/header.md +++ b/src/content/programme/tutorials/header.md @@ -8,7 +8,7 @@ meta: - text: "Small-group format" cta: text: "Browse all tutorials" - url: "/tutorials" + url: "/schedule/tutorials" advantages: - title: "3-hour sessions" description: diff --git a/src/data/nav.ts b/src/data/nav.ts index 9be9f3f66..4bc9ed189 100644 --- a/src/data/nav.ts +++ b/src/data/nav.ts @@ -33,8 +33,9 @@ const L = { // Programme overview: { label: "Overview", url: "/overview" }, schedule: { label: "Schedule", url: "/schedule" }, - tutorials: { label: "Tutorials", url: "/schedule/tutorials" }, - talks: { label: "Talks", url: "/schedule/talks" }, + keynotes: { label: "Keynotes", url: "/#keynoters" }, + tutorials: { label: "Tutorials Schedule", url: "/schedule/tutorials" }, + talks: { label: "Talks Schedule", url: "/schedule/talks" }, posters: { label: "Posters", url: "/posters" }, tracks: { label: "Tracks", url: "/tracks" }, speakers: { label: "Speakers", url: "/speakers" }, @@ -142,7 +143,6 @@ export const NAV_MENUS: NavMenu[] = [ label: "Talks & Schedule", items: [ L.overview, - L.schedule, L.tutorials, L.talks, L.posters, @@ -219,7 +219,15 @@ export const NAV_MENUS: NavMenu[] = [ label: "Community", url: "/about", sections: [ - { items: [L.about, L.eps, L.communityPartners, L.mediaPartners] }, + { + items: [ + L.about, + L.eps, + L.communityPartners, + L.mediaPartners, + L.yearsOfEp, + ], + }, ], }, @@ -268,7 +276,6 @@ export const FOOTER_COLUMNS: FooterColumn[] = [ { title: "Programme", items: [ - L.schedule, L.talks, L.tutorials, L.posters, diff --git a/src/layouts/ScheduleLayout.astro b/src/layouts/ScheduleLayout.astro index 522f4e103..1da89b07c 100644 --- a/src/layouts/ScheduleLayout.astro +++ b/src/layouts/ScheduleLayout.astro @@ -1,299 +1,382 @@ --- import Layout from "@layouts/Layout.astro"; import Section from "@ui/Section.astro"; -import { slugify } from "@utils/content"; +import { slugify } from '@utils/content'; export interface Props { title?: string; description: string; headline: string; + hideNotices?: string[]; } -const { title, description, headline } = Astro.props; ---- +const { title, description, headline, hideNotices = [] } = Astro.props; - +// Hide the notice grid on the schedule overview page, show on sub-pages +const showNoticeGrid = !Astro.url.pathname.match(/^\/schedule\/?$/); +--- + - - - - - + + + + +
-
-
-

- {headline} -

- -
- -
- -
- -
- 💚 Mark talks as favorites by opening their details and tapping the ❤️ - icon. Your selections are saved locally and will only be visible on - this device. -
-
+
+
+

{headline}

+ + + {showNoticeGrid && ( + + )} + +
+ +
+ +
+ +
💚 Mark talks as favorites by opening their details and tapping the ❤️ icon. Your selections are saved locally and will only be visible on this device.
+ +
+
diff --git a/src/pages/posters.astro b/src/pages/posters.astro index d95365ceb..e5b561b32 100644 --- a/src/pages/posters.astro +++ b/src/pages/posters.astro @@ -12,15 +12,9 @@ const posters = allSessions .sort((a, b) => a.data.title.localeCompare(b.data.title)); --- - + - +
@@ -34,6 +28,9 @@ const posters = allSessions + +
+
diff --git a/src/pages/schedule/talks.astro b/src/pages/schedule/talks.astro index feff227b4..3f24475cf 100644 --- a/src/pages/schedule/talks.astro +++ b/src/pages/schedule/talks.astro @@ -23,7 +23,7 @@ const days = await getCollection("days"); const talkDays = new Set(["2026-07-15", "2026-07-16", "2026-07-17"]); --- - + { days .filter((day) => talkDays.has(day.id)) diff --git a/src/pages/schedule/tutorials.astro b/src/pages/schedule/tutorials.astro index 349adf12c..5f630fe31 100644 --- a/src/pages/schedule/tutorials.astro +++ b/src/pages/schedule/tutorials.astro @@ -2,7 +2,7 @@ import { getCollection } from "astro:content"; import Layout from "@layouts/ScheduleLayout.astro"; import ScheduleDay from "@components/schedule/day.astro"; -import { slugify } from "@utils/content"; +import { slugify } from '@utils/content'; export const getStaticPaths = async () => { const sessions = await getCollection("sessions"); @@ -11,8 +11,8 @@ export const getStaticPaths = async () => { new Set( sessions .map((session) => slugify(session.data.session_type)) - .filter((type) => type), - ), + .filter((type) => type) + ) ).sort(); return allTypes.map((type) => { return { params: { type } }; @@ -21,15 +21,15 @@ export const getStaticPaths = async () => { const days = await getCollection("days"); const tutorialDays = new Set(["2026-07-13", "2026-07-14"]); + --- - + { - days - .filter((day) => tutorialDays.has(day.id)) - .map((day) => ) + days + .filter((day) => tutorialDays.has(day.id)) + .map((day) => ( + + )) } diff --git a/src/pages/session/[slug].astro b/src/pages/session/[slug].astro index 376d55bf4..43589ecfd 100644 --- a/src/pages/session/[slug].astro +++ b/src/pages/session/[slug].astro @@ -8,20 +8,26 @@ import { YouTube } from "@astro-community/astro-embed-youtube"; import { Picture } from "astro:assets"; import Markdown from "@ui/Markdown.astro"; import Section2 from "@ui/Section2.astro"; + // import CodeHeart from "@components/island/CodeHeart.svelte"; import Button from "@ui/Button.astro"; +import ScheduleRow from "@components/ScheduleRow.astro"; export async function getStaticPaths() { const sessions = await getCollection("sessions"); return sessions.map((entry) => ({ - params: { slug: entry.id }, + params: { slug: entry.id}, props: { entry }, })); } // Fetch the collection of sessions const sessions = await getCollection("sessions"); +const allSpeakers = await getCollection("speakers"); +const speakerNames = Object.fromEntries( + allSpeakers.map((s: any) => [s.id, s.data.name]) +); const { entry } = Astro.props; const slug = entry.id; @@ -40,6 +46,7 @@ speakers.sort((a, b) => { }); // Resolve session codes to session data + const resolveSessions = (codes: string[]) => codes.map((code) => sessions.find((s) => s.data.code === code)!); @@ -65,6 +72,10 @@ const nextSessionsOrdered = sameRoomNextSession ...nextSessionsInAllRooms.filter((s) => s !== sameRoomNextSession), ] : nextSessionsInAllRooms; +const nextTimeLabel = nextSessionsOrdered[0]?.data?.start + ? new Date(nextSessionsOrdered[0].data.start).toLocaleDateString("en-GB", { timeZone: "Europe/Warsaw", weekday: "short", day: "numeric", month: "short" }) + " · " + + new Date(nextSessionsOrdered[0].data.start).toLocaleTimeString("en-GB", { timeZone: "Europe/Warsaw", hour: "2-digit", minute: "2-digit" }) + : ""; --- @@ -75,11 +86,11 @@ const nextSessionsOrdered = sameRoomNextSession > {entry.data.title} - + @@ -113,7 +124,7 @@ const nextSessionsOrdered = sameRoomNextSession {formatInTimeZone( entry.data.start, "Europe/Warsaw", - "HH:mm 'on' EEEE, dd MMMM yyyy", + "HH:mm 'on' EEEE, dd MMMM yyyy" )} @@ -127,7 +138,7 @@ const nextSessionsOrdered = sameRoomNextSession {formatInTimeZone( entry.data.end, "Europe/Warsaw", - "HH:mm 'on' EEEE, dd MMMM yyyy", + "HH:mm 'on' EEEE, dd MMMM yyyy" )} @@ -136,9 +147,19 @@ const nextSessionsOrdered = sameRoomNextSession
Duration:
{entry.data.duration} minutes
- +
+ + { + entry.data.youtube_url && ( + + Watch on YouTube ↗ + + ) + } +
+

Abstract

@@ -232,44 +253,22 @@ const nextSessionsOrdered = sameRoomNextSession (nextSessionsInAllRooms?.length ?? 0) > 0 ? ( <> -
+ @@ -280,10 +279,36 @@ const nextSessionsOrdered = sameRoomNextSession diff --git a/src/pages/tutorials.astro b/src/pages/tutorials.astro index e6f1727c1..f63f6f291 100644 --- a/src/pages/tutorials.astro +++ b/src/pages/tutorials.astro @@ -3,6 +3,7 @@ import Layout from "@layouts/Layout.astro"; import Section2 from "@ui/Section2.astro"; import SessionHeader from "@sections/SessionHeader.astro"; import { getCollection, getEntry } from "astro:content"; +import SessionCard from "@components/SessionCard.astro"; const allSessions = await getCollection("sessions"); @@ -15,13 +16,11 @@ for (const s of allSpeakers) { function speakerHtml(speakers: any): string { if (!speakers || speakers.length === 0) return ""; - return speakers - .map((s: any) => { - const id = typeof s === "string" ? s : s.id; - const name = speakerNames[id] || id; - return `${name}`; - }) - .join(", "); + return speakers.map((s: any) => { + const id = typeof s === "string" ? s : s.id; + const name = speakerNames[id] || id; + return `${name}`; + }).join(", "); } const tutorialHeader = await getEntry("programme", "tutorials/header"); const tutorials = allSessions @@ -29,15 +28,9 @@ const tutorials = allSessions .sort((a, b) => a.data.title.localeCompare(b.data.title)); --- - + - + @@ -51,63 +44,34 @@ const tutorials = allSessions /> -

- {tutorials.length} tutorials at EuroPython 2026 -

+

{tutorials.length} tutorials at EuroPython 2026

- +
- { - tutorials.map((t) => ( -
- - {t.data.title} - -

-

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

{t.data.track}

- )} -
- )) - } + {tutorials.map((t) => ( + + ))}
- + +
@@ -160,132 +124,32 @@ const tutorials = allSessions grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } - - .tutorial-card { - display: flex; - flex-direction: column; - background: var(--color-surface-subtle); - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 1.25rem; - text-decoration: none; - font-family: "Inter Tight", system-ui, sans-serif; - transition: - border-color 0.1s ease-in-out, - background 0.1s ease-in-out; - } - - .tutorial-card:hover { - border-color: var(--ep-accent); - background: var(--ep-session-hover-bg) !important; - z-index: 10; - } - - .tutorial-card-title { - font-family: "Inter Tight", system-ui, sans-serif; - font-size: 1.25rem; - font-weight: 700; - color: var(--color-text); - margin: 0 0 0.5rem; - line-height: 1.35; - text-decoration: none; - } - - .tutorial-card-title:hover { - text-decoration: underline; - } - - .tutorial-card-speakers { - font-size: 1.15rem; - color: var(--color-text-secondary); - margin: 0 0 0.75rem; - } - - :global(.tutorial-card-speakers .speaker-link) { - color: var(--color-text-secondary); - text-decoration: none; - } - - :global(.tutorial-card-speakers .speaker-link:hover) { - text-decoration: underline; - } - - .tutorial-card-meta { - margin-top: auto; - padding-top: 2rem; - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - align-items: center; - } - - .tutorial-card-tag { - font-size: 1.02rem; - color: var(--color-text-muted); - background: var(--color-surface-medium); - padding: 0.2em 0.6em; - border-radius: 3px; - border: 1px solid var(--color-border); - } - - .tutorial-card-level { - display: inline-block; - padding: 0.2em 0.6em; - border-radius: 3px; - font-size: 1.02rem; - font-weight: 600; - letter-spacing: 0.03em; - border: 1px solid transparent; - } - - .tutorial-card-level--beginner { - background: var(--ep-level-beginner-bg); - color: var(--ep-level-beginner-text); - } - - .tutorial-card-level--intermediate { - background: var(--ep-level-intermediate-bg); - color: var(--ep-level-intermediate-text); - } - - .tutorial-card-level--advanced { - background: var(--ep-level-advanced-bg); - color: var(--ep-level-advanced-text); - } - - .tutorial-card-track { - font-family: "Inter Tight", system-ui, sans-serif; - font-size: 1.05rem; - font-weight: 600; - color: var(--color-text-secondary); - margin: 0.5rem 0 0; - } diff --git a/src/styles/light-theme.css b/src/styles/light-theme.css index e463bffa7..78c812b2e 100644 --- a/src/styles/light-theme.css +++ b/src/styles/light-theme.css @@ -251,6 +251,25 @@ } /* ── Light footer gradient ── */ -.light footer { +.light footer:not(#session-page *) { background: linear-gradient(135deg, #f4f5f7, #ebedef) !important; } + +.light #session-page footer { + background: none !important; +} + +/* Speaker links in Sessions at the same time cards — dark as heading */ +.light .sched-row-speaker-link { + color: var(--color-text) !important; +} + +/* Session detail page title — dark text on light */ +.light #session-page h1:first-child { + color: var(--color-text) !important; +} + +/* ScheduleRow session titles — dark on light */ +.light .sched-row-title { + color: var(--color-text) !important; +} diff --git a/src/utils/nav.ts b/src/utils/nav.ts index 4602830a1..3a4ea846f 100644 --- a/src/utils/nav.ts +++ b/src/utils/nav.ts @@ -6,6 +6,8 @@ const ALWAYS_EXIST = new Set([ "sessions", "speakers", "schedule", + "schedule/talks", + "schedule/tutorials", "posters", "talks", "tutorials", @@ -44,6 +46,7 @@ export async function buildLinkChecker(): Promise<(url: string) => boolean> { return function linkExists(url: string): boolean { if (url.startsWith("http")) return true; + if (url.startsWith("/#")) return true; // homepage anchor links always valid const slug = url.replace(/^\//, "").replace(/\/$/, ""); if (!slug) return true; if (ALWAYS_EXIST.has(slug)) return true;