atmo.rsvp
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

add atmosphere conf stuff

Florian 47d9aea4 ad83a8d3

+1589 -13
+1
package.json
··· 62 62 "@foxui/time": "^0.5.1", 63 63 "@foxui/visual": "^0.8.0", 64 64 "@internationalized/date": "^3.12.0", 65 + "@number-flow/svelte": "^0.4.0", 65 66 "@tailwindcss/typography": "^0.5.19", 66 67 "dompurify": "^3.3.3", 67 68 "maplibre-gl": "^5.20.2",
+3
pnpm-lock.yaml
··· 32 32 '@internationalized/date': 33 33 specifier: ^3.12.0 34 34 version: 3.12.0 35 + '@number-flow/svelte': 36 + specifier: ^0.4.0 37 + version: 0.4.0(svelte@5.48.0) 35 38 '@tailwindcss/typography': 36 39 specifier: ^0.5.19 37 40 version: 0.5.19(tailwindcss@4.2.1)
+272
ref/DaySchedule.astro
··· 1 + --- 2 + import ScheduleEventCell from "./ScheduleEventCell.astro"; 3 + 4 + interface Speaker { 5 + name: string; 6 + id?: string; 7 + } 8 + 9 + interface ScheduleEvent { 10 + id?: string; 11 + title: string; 12 + type: string; 13 + speakers?: Speaker[]; 14 + start?: string; 15 + end?: string; 16 + room?: string; 17 + description?: string; 18 + link_url?: string; 19 + link_text?: string; 20 + } 21 + 22 + interface Props { 23 + events: ScheduleEvent[]; 24 + rooms: string[]; 25 + } 26 + 27 + const { events, rooms } = Astro.props; 28 + 29 + // --- Time helpers --- 30 + 31 + const TZ = "America/Vancouver"; 32 + 33 + function isoToMinutes(iso: string): number { 34 + const d = new Date(iso); 35 + const parts = new Intl.DateTimeFormat("en-US", { 36 + timeZone: TZ, 37 + hour: "numeric", 38 + minute: "2-digit", 39 + hour12: false, 40 + }).formatToParts(d); 41 + const h = parseInt(parts.find((p) => p.type === "hour")!.value); 42 + const m = parseInt(parts.find((p) => p.type === "minute")!.value); 43 + return h * 60 + m; 44 + } 45 + 46 + function formatHour(minutes: number) { 47 + const h = Math.floor(minutes / 60); 48 + const p = h >= 12 ? "PM" : "AM"; 49 + const d = h === 0 ? 12 : h > 12 ? h - 12 : h; 50 + return `${d}${p}`; 51 + } 52 + 53 + // --- Grid math --- 54 + 55 + const parsed = events 56 + .filter((e) => e.start) 57 + .map((e) => { 58 + const start = isoToMinutes(e.start!); 59 + const end = e.end ? isoToMinutes(e.end) : start + 30; 60 + return { ...e, startMin: start, endMin: end }; 61 + }); 62 + 63 + const SLOT = 10; 64 + const minTime = 65 + Math.floor(Math.min(...parsed.map((e) => e.startMin)) / SLOT) * SLOT; 66 + const maxTime = Math.ceil(Math.max(...parsed.map((e) => e.endMin)) / SLOT) * SLOT; 67 + const totalSlots = (maxTime - minTime) / SLOT; 68 + 69 + const isInfo = (type: string) => type === "info"; 70 + 71 + const linkableTypes = new Set([ 72 + "workshop", 73 + "presentation", 74 + "lightning-talk", 75 + "panel", 76 + ]); 77 + function isLinkable(event: { id?: string; type: string }) { 78 + return !!(event.id && linkableTypes.has(event.type)); 79 + } 80 + 81 + const gridEvents = parsed 82 + .filter((e) => isInfo(e.type) || rooms.includes(e.room || "")) 83 + .filter((e) => !/breakfast/i.test(e.title)) 84 + .map((e, i, arr) => { 85 + const fullWidth = isInfo(e.type); 86 + return { 87 + ...e, 88 + startRow: Math.round((e.startMin - minTime) / SLOT) + 2, 89 + spanRows: Math.max(1, Math.round((e.endMin - e.startMin) / SLOT)), 90 + colStart: fullWidth ? 1 : rooms.indexOf(e.room!) + 1, 91 + colSpan: fullWidth ? rooms.length : 1, 92 + zIndex: e.startMin, 93 + }; 94 + }); 95 + 96 + // Shared time grid template 97 + const timeGridRows = `grid-template-rows: 0.5rem repeat(${totalSlots}, 2rem)`; 98 + --- 99 + 100 + <div class="day-schedule border-border rounded-lg border"> 101 + <!-- ========== MOBILE: Vertical room tabs + single-room panels ========== --> 102 + 103 + <div class="divide-border flex flex-col divide-y md:hidden"> 104 + { 105 + rooms.map((room, i) => ( 106 + <button 107 + data-room-tab={i} 108 + class:list={[ 109 + "room-tab px-3 py-2.5 text-left text-xs font-semibold tracking-wide uppercase transition-colors", 110 + i === 0 111 + ? "border-l-primary bg-accent/50 text-primary border-l-2" 112 + : "text-muted-foreground", 113 + ]} 114 + > 115 + {room} 116 + </button> 117 + )) 118 + } 119 + </div> 120 + 121 + { 122 + rooms.map((room, roomIndex) => { 123 + const roomEvents = gridEvents.filter( 124 + (e) => isInfo(e.type) || e.room === room, 125 + ); 126 + return ( 127 + <div 128 + data-room-panel={roomIndex} 129 + class:list={[ 130 + "border-border border-t md:hidden", 131 + roomIndex !== 0 && "hidden", 132 + ]} 133 + > 134 + <div class="flex"> 135 + <div class="border-border w-14 flex-none border-r" /> 136 + <div class="grid flex-auto grid-cols-1 grid-rows-1"> 137 + <div 138 + class="divide-border/40 col-start-1 col-end-2 row-start-1 grid divide-y" 139 + style={timeGridRows} 140 + > 141 + <div class="row-end-1 h-0" /> 142 + <div /> 143 + {Array.from({ length: totalSlots }).map((_, i) => ( 144 + <div> 145 + {(minTime + i * SLOT) % 60 === 0 && ( 146 + <div class="text-muted-foreground sticky left-0 z-20 -mt-2 -ml-14 w-14 pr-2 text-right text-[0.65rem] leading-none"> 147 + {formatHour(minTime + i * SLOT)} 148 + </div> 149 + )} 150 + </div> 151 + ))} 152 + </div> 153 + <ol 154 + class="col-start-1 col-end-2 row-start-1 grid" 155 + style={`grid-template-columns: 1fr; ${timeGridRows}`} 156 + > 157 + {roomEvents.map((event) => ( 158 + <li 159 + class:list={[ 160 + "relative flex min-h-5 p-0.5", 161 + isLinkable(event) && "group/evt cursor-pointer", 162 + ]} 163 + style={`grid-row: ${event.startRow} / span ${event.spanRows}; grid-column: 1; z-index: ${event.zIndex}`} 164 + > 165 + <ScheduleEventCell event={event} /> 166 + </li> 167 + ))} 168 + </ol> 169 + </div> 170 + </div> 171 + </div> 172 + ); 173 + }) 174 + } 175 + 176 + <!-- ========== DESKTOP: Full multi-column layout ========== --> 177 + <div class="hidden md:block"> 178 + <div> 179 + <div class="border-border bg-card flex rounded-t-lg border-b"> 180 + <div class="border-border w-14 flex-none border-r"></div> 181 + <div 182 + class="divide-border grid flex-auto divide-x" 183 + style={`grid-template-columns: repeat(${rooms.length}, minmax(0, 1fr))`} 184 + > 185 + { 186 + rooms.map((room) => ( 187 + <div class="text-muted-foreground px-2 py-2.5 text-center text-xs font-semibold tracking-wide uppercase"> 188 + {room} 189 + </div> 190 + )) 191 + } 192 + </div> 193 + </div> 194 + 195 + <div class="flex"> 196 + <div class="border-border w-14 flex-none border-r"></div> 197 + <div class="grid flex-auto grid-cols-1 grid-rows-1"> 198 + <div 199 + class="divide-border/40 col-start-1 col-end-2 row-start-1 grid divide-y" 200 + style={timeGridRows} 201 + > 202 + <div class="row-end-1 h-0"></div> 203 + <div></div> 204 + { 205 + Array.from({ length: totalSlots }).map((_, i) => ( 206 + <div> 207 + {(minTime + i * SLOT) % 60 === 0 && ( 208 + <div class="text-muted-foreground sticky left-0 z-20 -mt-2 -ml-14 w-14 pr-2 text-right text-[0.65rem] leading-none"> 209 + {formatHour(minTime + i * SLOT)} 210 + </div> 211 + )} 212 + </div> 213 + )) 214 + } 215 + </div> 216 + 217 + <div 218 + class="divide-border/40 col-start-1 col-end-2 row-start-1 grid grid-rows-1 divide-x" 219 + style={`grid-template-columns: repeat(${rooms.length}, minmax(0, 1fr))`} 220 + > 221 + {rooms.map(() => <div class="row-span-full" />)} 222 + </div> 223 + 224 + <ol 225 + class="col-start-1 col-end-2 row-start-1 grid" 226 + style={`grid-template-columns: repeat(${rooms.length}, minmax(0, 1fr)); ${timeGridRows}`} 227 + > 228 + { 229 + gridEvents.map((event) => ( 230 + <li 231 + class:list={[ 232 + "relative flex min-h-5 p-0.5", 233 + isLinkable(event) && "group/evt cursor-pointer", 234 + ]} 235 + style={`grid-row: ${event.startRow} / span ${event.spanRows}; grid-column: ${event.colStart} / span ${event.colSpan}; z-index: ${event.zIndex}`} 236 + > 237 + <ScheduleEventCell event={event} /> 238 + </li> 239 + )) 240 + } 241 + </ol> 242 + </div> 243 + </div> 244 + </div> 245 + </div> 246 + </div> 247 + 248 + <script> 249 + document.querySelectorAll(".day-schedule").forEach((schedule) => { 250 + const tabs = schedule.querySelectorAll<HTMLElement>("[data-room-tab]"); 251 + const panels = schedule.querySelectorAll<HTMLElement>("[data-room-panel]"); 252 + 253 + tabs.forEach((tab) => { 254 + tab.addEventListener("click", () => { 255 + const idx = tab.dataset.roomTab; 256 + 257 + tabs.forEach((t) => { 258 + const isActive = t === tab; 259 + t.classList.toggle("border-l-2", isActive); 260 + t.classList.toggle("border-l-primary", isActive); 261 + t.classList.toggle("text-primary", isActive); 262 + t.classList.toggle("bg-accent/50", isActive); 263 + t.classList.toggle("text-muted-foreground", !isActive); 264 + }); 265 + 266 + panels.forEach((p) => { 267 + p.classList.toggle("hidden", p.dataset.roomPanel !== idx); 268 + }); 269 + }); 270 + }); 271 + }); 272 + </script>
+142
ref/DayScheduleTable.astro
··· 1 + --- 2 + /** 3 + * Simple HTML table layout for the schedule — used by themes that prefer 4 + * a flat, retro look (geocities, aim) instead of the CSS-grid calendar. 5 + * 6 + * Receives the same props as DaySchedule so they're interchangeable. 7 + */ 8 + 9 + interface Speaker { 10 + name: string; 11 + id?: string; 12 + } 13 + 14 + interface ScheduleEvent { 15 + id?: string; 16 + title: string; 17 + type: string; 18 + speakers?: Speaker[]; 19 + start?: string; 20 + end?: string; 21 + room?: string; 22 + description?: string; 23 + link_url?: string; 24 + link_text?: string; 25 + } 26 + 27 + interface Props { 28 + events: ScheduleEvent[]; 29 + rooms: string[]; 30 + tableIndex?: number; 31 + } 32 + 33 + const { events, tableIndex = 0 } = Astro.props; 34 + 35 + function formatTime(iso: string) { 36 + const m = iso.match(/T(\d{2}):(\d{2})/); 37 + if (!m) return ""; 38 + let h = parseInt(m[1]); 39 + const min = m[2]; 40 + const p = h >= 12 ? "pm" : "am"; 41 + h = h === 0 ? 12 : h > 12 ? h - 12 : h; 42 + return min === "00" ? `${h}${p}` : `${h}:${min}${p}`; 43 + } 44 + 45 + const sorted = [...events] 46 + .filter((e) => e.start && e.type !== "info") 47 + .sort((a, b) => (a.start! > b.start! ? 1 : -1)); 48 + 49 + const infoEvents = events.filter((e) => e.type === "info" && e.start); 50 + 51 + const linkableTypes = new Set(["workshop", "presentation", "lightning-talk", "panel"]); 52 + --- 53 + 54 + <div class="schedule-table-wrap" data-table-index={tableIndex}> 55 + <table class="schedule-table"> 56 + <thead> 57 + <tr> 58 + <th>Time</th> 59 + <th>Workshop</th> 60 + <th>Who</th> 61 + </tr> 62 + </thead> 63 + <tbody> 64 + { 65 + infoEvents 66 + .sort((a, b) => (a.start! > b.start! ? 1 : -1)) 67 + .map((event) => { 68 + const time = `${formatTime(event.start!)}${event.end ? `–${formatTime(event.end)}` : ""}`; 69 + return ( 70 + <tr class="schedule-info-row"> 71 + <td colspan="3"> 72 + {event.title} · {time} 73 + </td> 74 + </tr> 75 + ); 76 + }) 77 + } 78 + { 79 + sorted.map((event) => { 80 + const time = `${formatTime(event.start!)}${event.end ? `–${formatTime(event.end)}` : ""}`; 81 + const speakers = event.speakers?.map((s) => s.name).join(", "); 82 + const href = event.id && linkableTypes.has(event.type) ? `/event/${event.id}` : null; 83 + return ( 84 + <tr> 85 + <td>{time}</td> 86 + <td> 87 + {href ? ( 88 + <a href={href} class="text-primary underline" set:html={event.title} /> 89 + ) : ( 90 + <span set:html={event.title} /> 91 + )} 92 + </td> 93 + <td>{speakers}</td> 94 + </tr> 95 + ); 96 + }) 97 + } 98 + </tbody> 99 + </table> 100 + </div> 101 + 102 + <style> 103 + .schedule-table { 104 + width: 100%; 105 + border-collapse: collapse; 106 + font-size: 0.85rem; 107 + } 108 + .schedule-table th { 109 + background: var(--table-header-bg, var(--primary)); 110 + color: var(--table-header-fg, var(--primary-foreground)); 111 + border: var(--table-border-width, 1px) solid var(--table-border-color, var(--border)); 112 + padding: 0.35rem 0.5rem; 113 + text-align: left; 114 + font-weight: 600; 115 + font-family: var(--font-ui); 116 + } 117 + .schedule-table td { 118 + background: var(--table-cell-bg, transparent); 119 + border: var(--table-border-width, 1px) solid var(--table-border-color, var(--border)); 120 + padding: 0.25rem 0.5rem; 121 + vertical-align: top; 122 + } 123 + .schedule-table tbody tr:nth-child(even) td { 124 + background: var(--table-cell-bg-alt, var(--table-cell-bg, transparent)); 125 + } 126 + .schedule-table { 127 + border: var(--table-frame-width, 0) solid var(--table-border-color, var(--border)); 128 + } 129 + .schedule-table-wrap { 130 + margin-left: calc(-1 * var(--card-px, 2rem)); 131 + margin-right: calc(-1 * var(--card-px, 2rem)); 132 + width: calc(100% + 2 * var(--card-px, 2rem)); 133 + } 134 + .schedule-table tbody tr:hover { 135 + background: var(--accent); 136 + } 137 + .schedule-info-row td { 138 + text-align: center; 139 + font-weight: 600; 140 + background: var(--accent); 141 + } 142 + </style>
+109
ref/ScheduleEventCell.astro
··· 1 + --- 2 + interface Speaker { 3 + name: string; 4 + id?: string; 5 + } 6 + 7 + interface Props { 8 + event: { 9 + id?: string; 10 + title: string; 11 + type: string; 12 + speakers?: Speaker[]; 13 + start?: string; 14 + end?: string; 15 + description?: string; 16 + }; 17 + } 18 + 19 + const { event } = Astro.props; 20 + 21 + const linkableTypes = new Set([ 22 + "workshop", 23 + "presentation", 24 + "lightning-talk", 25 + "panel", 26 + ]); 27 + const href = 28 + event.id && linkableTypes.has(event.type) ? `/event/${event.id}` : null; 29 + const isInfo = event.type === "info"; 30 + 31 + function formatTime(iso: string) { 32 + const date = new Date(iso); 33 + if (isNaN(date.getTime())) return ""; 34 + return date 35 + .toLocaleTimeString("en-US", { 36 + timeZone: "America/Vancouver", 37 + hour: "numeric", 38 + minute: "2-digit", 39 + hour12: true, 40 + }) 41 + .toLowerCase() 42 + .replace(" ", ""); 43 + } 44 + 45 + const durationMin = 46 + event.start && event.end 47 + ? (new Date(event.end).getTime() - new Date(event.start).getTime()) / 60_000 48 + : 0; 49 + 50 + const time = (() => { 51 + if (!event.start || durationMin <= 10) return ""; 52 + const s = formatTime(event.start); 53 + const e = event.end ? formatTime(event.end) : ""; 54 + return e ? `${s} – ${e}` : s; 55 + })(); 56 + 57 + function getColors(type: string) { 58 + switch (type) { 59 + case "info": 60 + return "event-badge event-badge-info"; 61 + case "activity": 62 + return "event-badge event-badge-activity"; 63 + case "workshop": 64 + return "event-badge event-badge-workshop"; 65 + case "lightning-talk": 66 + return "event-badge event-badge-lightning"; 67 + case "panel": 68 + return "event-badge event-badge-panel"; 69 + default: 70 + return "event-badge event-badge-presentation"; 71 + } 72 + } 73 + 74 + function getSubColors(type: string) { 75 + switch (type) { 76 + case "info": 77 + return "event-sub-info"; 78 + case "activity": 79 + return "event-sub-activity"; 80 + case "workshop": 81 + return "event-sub-workshop"; 82 + case "lightning-talk": 83 + return "event-sub-lightning"; 84 + case "panel": 85 + return "event-sub-panel"; 86 + default: 87 + return "event-sub-presentation"; 88 + } 89 + } 90 + 91 + const speakerNames = event.speakers?.map((s) => s.name).join(", "); 92 + --- 93 + 94 + <Fragment> 95 + {href && <a href={href} class="absolute inset-0 z-10" />} 96 + <div 97 + class:list={[ 98 + "flex-1 overflow-hidden rounded-md px-2 py-1.5 text-xs leading-tight transition-[filter]", 99 + getColors(event.type), 100 + isInfo && "flex flex-col items-center justify-center text-center", 101 + href && "group-hover/evt:brightness-95", 102 + ]} 103 + > 104 + <p class="truncate font-semibold" set:html={event.title} /> 105 + {time && ( 106 + <p class:list={["truncate", getSubColors(event.type)]}>{time}</p> 107 + )} 108 + </div> 109 + </Fragment>
+167
ref/WorkshopsSection.astro
··· 1 + --- 2 + import Card from "@/components/ui/card.astro"; 3 + import DaySchedule from "@/components/ui/DaySchedule.astro"; 4 + import DayScheduleTable from "@/components/ui/DayScheduleTable.astro"; 5 + import { getLiveCollection } from "astro:content"; 6 + 7 + const { entries: events = [], error } = await getLiveCollection("events"); 8 + if (error) { 9 + throw error; 10 + } 11 + 12 + const TZ = "America/Vancouver"; 13 + const localDate = (iso: string) => 14 + new Date(iso).toLocaleDateString("en-CA", { timeZone: TZ }); 15 + 16 + const allEvents = events.filter((e) => e.data.start); 17 + 18 + const workshopRooms = [ 19 + "2301 Classroom", 20 + "2311 Classroom", 21 + "Performance Theatre", 22 + "Bukhman Lounge", 23 + ]; 24 + 25 + const conferenceRooms = [ 26 + "Great Hall South", 27 + "Performance Theatre", 28 + "Room 2301", 29 + ]; 30 + 31 + const tabs = [ 32 + { 33 + id: "workshops", 34 + label: "Workshops", 35 + days: [ 36 + { 37 + date: "2026-03-26", 38 + heading: "Thursday, March 26th: Workshops", 39 + rooms: workshopRooms, 40 + }, 41 + { 42 + date: "2026-03-27", 43 + heading: "Friday, March 27th: Workshops & ATScience", 44 + rooms: workshopRooms, 45 + }, 46 + ], 47 + }, 48 + { 49 + id: "talks", 50 + label: "Talks", 51 + days: [ 52 + { 53 + date: "2026-03-28", 54 + heading: "Saturday, March 28th: Conference Day 1", 55 + rooms: conferenceRooms, 56 + }, 57 + { 58 + date: "2026-03-29", 59 + heading: "Sunday, March 29th: Conference Day 2", 60 + rooms: conferenceRooms, 61 + }, 62 + ], 63 + }, 64 + ] as const; 65 + 66 + let tableCounter = 0; 67 + --- 68 + 69 + <section> 70 + <Card title="schedule"> 71 + We have two days of pre-conference events and activities, happening 72 + Thursday, March 26th and Friday, March 27th, and two days of talks, panels, 73 + and lightning talks on Saturday, March 28th and Sunday, March 29th. 74 + <div class="bg-card mt-8 flex gap-2 py-2" id="schedule-tab-bar"> 75 + { 76 + tabs.map((tab, i) => ( 77 + <button 78 + data-schedule-tab={tab.id} 79 + class:list={[ 80 + "schedule-tab rounded-md px-4 py-2 text-sm font-semibold transition-colors", 81 + i === 0 82 + ? "bg-primary text-primary-foreground" 83 + : "bg-secondary text-secondary-foreground", 84 + ]} 85 + > 86 + {tab.label} 87 + </button> 88 + )) 89 + } 90 + </div> 91 + 92 + { 93 + tabs.map((tab, i) => ( 94 + <div data-schedule-panel={tab.id} class:list={[i !== 0 && "hidden"]}> 95 + {tab.id === "workshops" && ( 96 + <p class="text-foreground mt-4"> 97 + Workshops are included with your ticket. There is a maximum 98 + capacity, so please do select activities in your ticket 99 + registration form. 100 + </p> 101 + )} 102 + {tab.days.map(({ date, heading, rooms }) => { 103 + const dayEvents = allEvents 104 + .filter((w) => w.data.start && localDate(w.data.start) === date) 105 + .map((w) => ({ id: w.id, ...w.data })); 106 + const ti = tableCounter++; 107 + 108 + return ( 109 + <div class="mt-6 overflow-hidden"> 110 + <h3 class="text-foreground mb-4 text-lg font-semibold"> 111 + {heading} 112 + </h3> 113 + <div class="schedule-grid-view"> 114 + <DaySchedule events={dayEvents} rooms={rooms} /> 115 + </div> 116 + <div class="schedule-table-view"> 117 + <DayScheduleTable events={dayEvents} rooms={rooms} tableIndex={ti} /> 118 + </div> 119 + </div> 120 + ); 121 + })} 122 + <div class="mt-6 flex justify-end"> 123 + <button 124 + data-schedule-goto={tabs[1 - i].id} 125 + class="bg-secondary text-secondary-foreground rounded-md px-4 py-2 text-sm font-semibold transition-colors" 126 + > 127 + Go to {tabs[1 - i].label} → 128 + </button> 129 + </div> 130 + </div> 131 + )) 132 + } 133 + </Card> 134 + </section> 135 + 136 + <script> 137 + const tabs = document.querySelectorAll<HTMLElement>("[data-schedule-tab]"); 138 + const panels = document.querySelectorAll<HTMLElement>( 139 + "[data-schedule-panel]", 140 + ); 141 + const gotos = document.querySelectorAll<HTMLElement>("[data-schedule-goto]"); 142 + 143 + function switchTab(id: string | undefined) { 144 + if (!id) return; 145 + tabs.forEach((t) => { 146 + const active = t.dataset.scheduleTab === id; 147 + t.classList.toggle("bg-primary", active); 148 + t.classList.toggle("text-primary-foreground", active); 149 + t.classList.toggle("bg-secondary", !active); 150 + t.classList.toggle("text-secondary-foreground", !active); 151 + }); 152 + panels.forEach((p) => { 153 + p.classList.toggle("hidden", p.dataset.schedulePanel !== id); 154 + }); 155 + } 156 + 157 + tabs.forEach((tab) => { 158 + tab.addEventListener("click", () => switchTab(tab.dataset.scheduleTab)); 159 + }); 160 + 161 + gotos.forEach((btn) => { 162 + btn.addEventListener("click", () => { 163 + switchTab(btn.dataset.scheduleGoto); 164 + tabs[0]?.closest("section")?.scrollIntoView({ behavior: "smooth" }); 165 + }); 166 + }); 167 + </script>
+1 -1
src/lib/components/EventEditor.svelte
··· 741 741 required 742 742 placeholder="Event name" 743 743 rows={2} 744 - class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent px-0 text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 744 + class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent px-0 text-3xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-4xl" 745 745 ></textarea> 746 746 </div> 747 747
+1
src/lib/event-types.ts
··· 20 20 index: { byteStart: number; byteEnd: number }; 21 21 features: Array<{ $type: string; did?: string; uri?: string; tag?: string }>; 22 22 }>; 23 + additionalData?: Record<string, unknown>; 23 24 };
+31 -3
src/routes/p/[actor]/e/[rkey]/+page.server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 + import type { ActorIdentifier } from '@atcute/lexicons'; 2 3 import { getActor } from '$lib/actor'; 3 4 import { 4 5 flattenEventRecord, 5 6 getEventRecordFromContrail, 6 7 getHostProfile, 8 + getProfileBlobUrl, 9 + getProfileFromContrail, 7 10 getRsvpStatus, 8 11 getViewerRsvpFromContrail, 9 12 listEventAttendeesFromContrail, ··· 34 37 } 35 38 36 39 const fullEventRecord = eventRecord!; 37 - const [attendees, viewerRsvpRecord] = await Promise.all([ 40 + const isAtmosphereconf = !!(eventData.additionalData as Record<string, unknown> | undefined)?.isAtmosphereconf; 41 + 42 + const speakers = ((eventData.additionalData as Record<string, unknown> | undefined)?.speakers as 43 + | Array<{ id: string; name: string }> 44 + | undefined) ?? []; 45 + 46 + const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ 38 47 listEventAttendeesFromContrail(fullEventRecord.uri), 39 48 locals.did 40 49 ? getViewerRsvpFromContrail({ eventUri: fullEventRecord.uri, actor: locals.did }) 41 - : null 50 + : null, 51 + isAtmosphereconf 52 + ? getEventRecordFromContrail({ did: 'did:plc:lehcqqkwzcwvjvw66uthu5oq', rkey: '3lte3c7x43l2e', profiles: true }) 53 + .then((r) => r ? flattenEventRecord(r) : null) 54 + .catch(() => null) 55 + : null, 56 + ...speakers.map((s) => 57 + s.id 58 + ? getProfileFromContrail(s.id as ActorIdentifier) 59 + .then((p) => ({ 60 + id: s.id, 61 + name: s.name, 62 + avatar: p?.record?.avatar ? getProfileBlobUrl(p.did, p.record.avatar) : undefined, 63 + handle: p?.handle || s.id 64 + })) 65 + .catch(() => ({ id: s.id, name: s.name, avatar: undefined, handle: s.id })) 66 + : Promise.resolve({ id: undefined, name: s.name, avatar: undefined, handle: undefined }) 67 + ) 42 68 ]); 43 69 44 70 return { ··· 48 74 hostProfile: getHostProfile(did, fullEventRecord.profiles) ?? null, 49 75 attendees, 50 76 viewerRsvpStatus: getRsvpStatus(viewerRsvpRecord?.record?.status), 51 - viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null 77 + viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null, 78 + parentEvent, 79 + speakerProfiles: speakerProfiles as Array<{ id?: string; name: string; avatar?: string; handle?: string }> 52 80 }; 53 81 } catch (e) { 54 82 if (e && typeof e === 'object' && 'status' in e) throw e;
+81 -9
src/routes/p/[actor]/e/[rkey]/+page.svelte
··· 7 7 import ShareModal from '$lib/components/ShareModal.svelte'; 8 8 import Avatar from 'svelte-boring-avatars'; 9 9 import EventRsvp from './EventRsvp.svelte'; 10 + import EventCard from '$lib/components/EventCard.svelte'; 10 11 import EventAttendees from './EventAttendees.svelte'; 11 12 import { page } from '$app/state'; 12 13 import { goto } from '$app/navigation'; ··· 25 26 26 27 let hostUrl = $derived(`/p/${hostProfile?.handle || did}`); 27 28 let eventPath = $derived(`/p/${hostProfile?.handle || did}/e/${data.rkey}`); 28 - let shareUrl = $derived(typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath); 29 + let shareUrl = $derived( 30 + typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath 31 + ); 29 32 30 33 let startDate = $derived(new Date(eventData.startsAt)); 31 34 let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); ··· 252 255 253 256 let isOwner = $derived(user.isLoggedIn && user.did === did); 254 257 258 + let speakers = $derived(data.speakerProfiles ?? []); 259 + 255 260 let attendeesRef: EventAttendees | undefined = $state(); 256 261 257 262 function handleRsvp(status: 'going' | 'interested') { ··· 264 269 handle: user.profile?.handle, 265 270 url: `/${user.profile?.handle || user.did}` 266 271 }); 267 - if(status === 'interested') return; 272 + if (status === 'interested') return; 268 273 shareModalTitle = "You're going!"; 269 274 shareModalText = `I'm going to "${eventData.name}"!\n\n${shareUrl}`; 270 275 showShareModal = true; ··· 345 350 <!-- Right column: event details --> 346 351 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1"> 347 352 <div class="mb-2"> 348 - <h1 class="text-base-900 dark:text-base-50 text-4xl leading-tight font-bold sm:text-5xl"> 353 + <h1 class="text-base-900 dark:text-base-50 text-3xl leading-tight font-bold sm:text-4xl"> 349 354 {eventData.name} 350 355 </h1> 351 356 </div> ··· 355 360 <div class="mb-8 flex items-center gap-2"> 356 361 {#if isOngoing} 357 362 <Badge size="md" variant="primary"> 358 - <span class="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-accent-500"></span> 363 + <span class="bg-accent-500 mr-1 inline-block size-1.5 animate-pulse rounded-full" 364 + ></span> 359 365 Live 360 366 </Badge> 361 367 {/if} ··· 397 403 398 404 <!-- Location row --> 399 405 {#if locationData} 400 - <a href={locationData.mapsUrl} target="_blank" rel="noopener noreferrer" class="mb-6 flex items-center gap-4 hover:opacity-80 transition-opacity"> 406 + <a 407 + href={locationData.mapsUrl} 408 + target="_blank" 409 + rel="noopener noreferrer" 410 + class="mb-6 flex items-center gap-4 transition-opacity hover:opacity-80" 411 + > 401 412 <div 402 413 class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 403 414 > ··· 426 437 <p class="text-base-900 dark:text-base-50 font-semibold">{locationData.name}</p> 427 438 <p class="text-base-500 dark:text-base-400 text-sm">{locationData.shortAddress}</p> 428 439 {:else} 429 - <p class="text-base-900 dark:text-base-50 font-semibold">{locationData.shortAddress}</p> 440 + <p class="text-base-900 dark:text-base-50 font-semibold"> 441 + {locationData.shortAddress} 442 + </p> 430 443 {/if} 431 444 </div> 432 445 </a> 433 446 {/if} 434 447 448 + <!-- Part of --> 449 + {#if data.parentEvent} 450 + <div 451 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 mt-8 mb-2 justify-center rounded-2xl border p-4" 452 + > 453 + <p 454 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 455 + > 456 + Part of 457 + </p> 458 + <EventCard event={data.parentEvent} actor="atprotocol.dev" /> 459 + <Button href="/p/atmosphereconf.org" size="lg" class="mt-6 w-full"> 460 + See full schedule 461 + </Button> 462 + </div> 463 + {/if} 464 + 465 + {#if did === 'did:plc:lehcqqkwzcwvjvw66uthu5oq' && rkey === '3lte3c7x43l2e'} 466 + <Button href="/p/atmosphereconf.org" size="lg" class="mb-4 w-full"> 467 + See full schedule 468 + </Button> 469 + {/if} 470 + 435 471 <EventRsvp 436 472 {eventUri} 437 473 eventCid={eventData.cid ?? null} ··· 465 501 > 466 502 Location 467 503 </p> 468 - <a href={locationData.mapsUrl} target="_blank" rel="noopener noreferrer" class="block hover:opacity-80 transition-opacity"> 504 + <a 505 + href={locationData.mapsUrl} 506 + target="_blank" 507 + rel="noopener noreferrer" 508 + class="block transition-opacity hover:opacity-80" 509 + > 469 510 <div class="h-64 w-full overflow-hidden rounded-xl"> 470 511 <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 471 512 </div> ··· 488 529 </p> 489 530 <a 490 531 href={hostUrl} 491 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium hover:underline" 532 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 492 533 > 493 534 <FoxAvatar 494 535 src={hostProfile?.avatar} ··· 501 542 </a> 502 543 </div> 503 544 545 + <!-- Speakers --> 546 + {#if speakers.length > 0} 547 + <div> 548 + <p 549 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 550 + > 551 + Speakers 552 + </p> 553 + <div class="space-y-2"> 554 + {#each speakers as speaker, i (speaker.id || i)} 555 + {#if speaker.handle} 556 + <a 557 + href="/p/{speaker.handle}" 558 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 559 + > 560 + <FoxAvatar src={speaker.avatar} alt={speaker.name} class="size-8 shrink-0" /> 561 + <span class="truncate text-sm">{speaker.name}</span> 562 + </a> 563 + {:else} 564 + <div 565 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium" 566 + > 567 + <FoxAvatar alt={speaker.name} class="size-8 shrink-0" /> 568 + <span class="truncate text-sm">{speaker.name}</span> 569 + </div> 570 + {/if} 571 + {/each} 572 + </div> 573 + </div> 574 + {/if} 575 + 504 576 {#if eventData.uris && eventData.uris.length > 0} 505 577 <!-- Links --> 506 578 <div> ··· 579 651 title={shareModalTitle} 580 652 shareText={shareModalText} 581 653 eventName={eventData.name} 582 - ogImageUrl={ogImageUrl} 654 + {ogImageUrl} 583 655 />
+48
src/routes/p/atmosphereconf.org/+page.server.ts
··· 1 + import { 2 + flattenEventRecords, 3 + getProfileFromContrail, 4 + listEventRecordsFromContrail, 5 + contrail 6 + } from '$lib/contrail'; 7 + 8 + export async function load({ locals }) { 9 + const actor = 'atmosphereconf.org'; 10 + 11 + const [profile, response, rsvpResponse] = await Promise.all([ 12 + getProfileFromContrail(actor), 13 + listEventRecordsFromContrail({ 14 + actor, 15 + profiles: true, 16 + sort: 'startsAt', 17 + order: 'asc', 18 + limit: 200 19 + }), 20 + locals.did 21 + ? contrail.get('community.lexicon.calendar.rsvp.listRecords', { 22 + params: { actor: locals.did, limit: 200 } 23 + }) 24 + : null 25 + ]); 26 + 27 + const events = response ? flattenEventRecords(response.records) : []; 28 + 29 + // Build a set of event URIs the user has RSVP'd to (going or interested) 30 + const rsvpEventUris = new Set<string>(); 31 + if (rsvpResponse?.ok) { 32 + for (const r of rsvpResponse.data.records ?? []) { 33 + const status = r.record?.status; 34 + if (status?.endsWith('#going') || status?.endsWith('#interested')) { 35 + const uri = r.record?.subject?.uri; 36 + if (uri) rsvpEventUris.add(uri); 37 + } 38 + } 39 + } 40 + 41 + return { 42 + profile, 43 + events, 44 + actor, 45 + rsvpEventUris: [...rsvpEventUris], 46 + loggedIn: !!locals.did 47 + }; 48 + }
+202
src/routes/p/atmosphereconf.org/+page.svelte
··· 1 + <script lang="ts"> 2 + import { getProfileBlobUrl } from '$lib/contrail'; 3 + import UserProfile from '$lib/components/UserProfile.svelte'; 4 + import { Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 5 + import DaySchedule from './DaySchedule.svelte'; 6 + import Countdown from './Countdown.svelte'; 7 + import { 8 + getScheduleEvents, 9 + getDayKey, 10 + getDayLabel, 11 + getRooms, 12 + buildGrid, 13 + isoToMinutes 14 + } from './schedule-utils'; 15 + import { onMount } from 'svelte'; 16 + 17 + let { data } = $props(); 18 + 19 + let hostProfile = $derived(data.profile); 20 + let hostDid = $derived(hostProfile?.did ?? ''); 21 + let hostName = $derived( 22 + hostProfile?.record?.displayName || hostProfile?.handle || 'ATmosphereConf' 23 + ); 24 + 25 + let scheduleEvents = $derived(getScheduleEvents(data.events)); 26 + let rsvpUris = $derived(new Set(data.rsvpEventUris ?? [])); 27 + let filterMode: string = $state('all'); 28 + let selectedDay: string = $state('all'); 29 + 30 + let dayGroups = $derived.by(() => { 31 + const groups = new Map< 32 + string, 33 + { key: string; label: string; shortLabel: string; events: typeof scheduleEvents } 34 + >(); 35 + for (const event of scheduleEvents) { 36 + const key = getDayKey(event.start); 37 + if (!groups.has(key)) { 38 + const label = getDayLabel(event.start); 39 + const shortLabel = new Date(event.start).toLocaleDateString('en-US', { weekday: 'short', timeZone: 'America/Vancouver' }); 40 + groups.set(key, { key, label, shortLabel, events: [] }); 41 + } 42 + groups.get(key)!.events.push(event); 43 + } 44 + return [...groups.values()]; 45 + }); 46 + 47 + let filteredDayGroups = $derived( 48 + selectedDay === 'all' ? dayGroups : dayGroups.filter((d) => d.key === selectedDay) 49 + ); 50 + 51 + let activeRooms: Record<number, number> = $state({}); 52 + 53 + let now = $state(new Date()); 54 + 55 + onMount(() => { 56 + const interval = setInterval(() => { 57 + now = new Date(); 58 + }, 60_000); 59 + return () => clearInterval(interval); 60 + }); 61 + 62 + let nowIso = $derived(now.toISOString()); 63 + let nowVancouverKey = $derived(getDayKey(nowIso)); 64 + let nowVancouverMinutes = $derived(isoToMinutes(nowIso)); 65 + 66 + let firstEventStart = $derived(scheduleEvents.length > 0 ? scheduleEvents[0].start : null); 67 + let isBeforeConference = $derived(firstEventStart ? now < new Date(firstEventStart) : false); 68 + 69 + let isNowDuringConference = $derived.by(() => { 70 + if (scheduleEvents.length === 0) return false; 71 + const first = scheduleEvents[0].start; 72 + const last = scheduleEvents[scheduleEvents.length - 1]; 73 + const lastEnd = last.end || last.start; 74 + return now >= new Date(first) && now <= new Date(lastEnd); 75 + }); 76 + 77 + function scrollToNow() { 78 + const els = document.querySelectorAll('[data-now-line]'); 79 + for (const el of els) { 80 + if (el instanceof HTMLElement && el.offsetParent !== null) { 81 + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); 82 + return; 83 + } 84 + } 85 + els[0]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 86 + } 87 + </script> 88 + 89 + <svelte:head> 90 + <title>{hostName}</title> 91 + </svelte:head> 92 + 93 + <div class="px-6 py-1 sm:py-2"> 94 + <div class="mx-auto max-w-3xl px-0 sm:px-4"> 95 + <UserProfile 96 + profile={{ 97 + handle: hostProfile?.handle, 98 + displayName: hostName, 99 + avatar: hostProfile?.record?.avatar 100 + ? getProfileBlobUrl(hostDid, hostProfile.record.avatar) 101 + : undefined 102 + }} 103 + /> 104 + 105 + <p class="text-base-500 dark:text-base-400 mb-2 text-sm"> 106 + See all info on the <a 107 + href="https://atmosphereconf.org/" 108 + target="_blank" 109 + rel="noopener noreferrer" 110 + class="text-accent-600 dark:text-accent-400 hover:underline">official website</a 111 + >. All times are shown in your local timezone. 112 + </p> 113 + 114 + {#if isBeforeConference && firstEventStart} 115 + <div class="my-24"> 116 + <Countdown targetDate={firstEventStart} /> 117 + </div> 118 + {/if} 119 + 120 + <div class="mt-8 mb-6 flex items-center justify-between"> 121 + <h2 class="text-base-900 dark:text-base-50 text-2xl font-bold">Schedule</h2> 122 + {#if isNowDuringConference} 123 + <Button onclick={scrollToNow} size="sm"> 124 + <svg 125 + xmlns="http://www.w3.org/2000/svg" 126 + fill="none" 127 + viewBox="0 0 24 24" 128 + stroke-width="2" 129 + stroke="currentColor" 130 + class="size-4" 131 + > 132 + <path 133 + stroke-linecap="round" 134 + stroke-linejoin="round" 135 + d="M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3" 136 + /> 137 + </svg> 138 + Jump to now 139 + </Button> 140 + {/if} 141 + </div> 142 + 143 + <div class="mb-6 space-y-3"> 144 + <div class="flex items-center gap-3"> 145 + <span class="text-base-500 dark:text-base-400 w-14 text-xs">Days</span> 146 + <ToggleGroup 147 + type="single" 148 + bind:value={ 149 + () => selectedDay, 150 + (v) => { 151 + if (v) selectedDay = v; 152 + } 153 + } 154 + class="w-fit" 155 + > 156 + <ToggleGroupItem value="all" size="sm">All</ToggleGroupItem> 157 + {#each dayGroups as day} 158 + <ToggleGroupItem value={day.key} size="sm">{day.shortLabel}</ToggleGroupItem> 159 + {/each} 160 + </ToggleGroup> 161 + </div> 162 + {#if data.loggedIn} 163 + <div class="flex items-center gap-3"> 164 + <span class="text-base-500 dark:text-base-400 w-14 text-xs">Events</span> 165 + <ToggleGroup 166 + type="single" 167 + bind:value={ 168 + () => filterMode, 169 + (v) => { 170 + if (v) filterMode = v; 171 + } 172 + } 173 + class="w-fit" 174 + > 175 + <ToggleGroupItem value="all" size="sm">All</ToggleGroupItem> 176 + <ToggleGroupItem value="attending" size="sm">Attending</ToggleGroupItem> 177 + </ToggleGroup> 178 + </div> 179 + {/if} 180 + </div> 181 + 182 + {#each filteredDayGroups as day, dayIndex} 183 + {@const rooms = getRooms(day.events)} 184 + {@const grid = buildGrid(day.events, rooms)} 185 + 186 + <section class="isolate mb-12"> 187 + <h3 class="text-base-900 dark:text-base-50 mb-4 text-lg font-bold">{day.label}</h3> 188 + <DaySchedule 189 + {grid} 190 + {rooms} 191 + {dayIndex} 192 + dayEvents={day.events} 193 + bind:activeRoom={() => activeRooms[dayIndex] ?? 0, (v) => (activeRooms[dayIndex] = v)} 194 + {nowVancouverKey} 195 + {nowVancouverMinutes} 196 + {rsvpUris} 197 + dimUnattended={filterMode === 'attending'} 198 + /> 199 + </section> 200 + {/each} 201 + </div> 202 + </div>
+63
src/routes/p/atmosphereconf.org/Countdown.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import { onMount } from 'svelte'; 4 + 5 + let { targetDate }: { targetDate: string } = $props(); 6 + 7 + let now = $state(Date.now()); 8 + 9 + onMount(() => { 10 + const interval = setInterval(() => { 11 + now = Date.now(); 12 + }, 1000); 13 + return () => clearInterval(interval); 14 + }); 15 + 16 + let remaining = $derived(Math.max(0, new Date(targetDate).getTime() - now)); 17 + 18 + let dd = $derived(Math.floor(remaining / 86400000)); 19 + let hh = $derived(Math.floor((remaining % 86400000) / 3600000)); 20 + let mm = $derived(Math.floor((remaining % 3600000) / 60000)); 21 + let ss = $derived(Math.floor((remaining % 60000) / 1000)); 22 + </script> 23 + 24 + {#if remaining > 0} 25 + <div class="text-center"> 26 + <p class="text-base-500 dark:text-base-400 mb-3 text-sm font-medium uppercase tracking-wider"> 27 + Doors open in 28 + </p> 29 + <NumberFlowGroup> 30 + <div 31 + class="flex w-full justify-center gap-4 sm:gap-6" 32 + style="font-variant-numeric: tabular-nums;" 33 + > 34 + {#if dd > 0} 35 + <div class="flex flex-col items-center"> 36 + <div class="text-base-900 dark:text-base-100 text-5xl font-bold sm:text-7xl"> 37 + <NumberFlow value={dd} trend={-1} format={{ minimumIntegerDigits: 2 }} /> 38 + </div> 39 + <span class="text-base-400 dark:text-base-500 mt-1 text-xs font-medium uppercase tracking-wider">days</span> 40 + </div> 41 + {/if} 42 + <div class="flex flex-col items-center"> 43 + <div class="text-base-900 dark:text-base-100 text-5xl font-bold sm:text-7xl"> 44 + <NumberFlow value={hh} trend={-1} format={{ minimumIntegerDigits: 2 }} /> 45 + </div> 46 + <span class="text-base-400 dark:text-base-500 mt-1 text-xs font-medium uppercase tracking-wider">hours</span> 47 + </div> 48 + <div class="flex flex-col items-center"> 49 + <div class="text-base-900 dark:text-base-100 text-5xl font-bold sm:text-7xl"> 50 + <NumberFlow value={mm} trend={-1} format={{ minimumIntegerDigits: 2 }} digits={{ 1: { max: 5 } }} /> 51 + </div> 52 + <span class="text-base-400 dark:text-base-500 mt-1 text-xs font-medium uppercase tracking-wider">mins</span> 53 + </div> 54 + <div class="flex flex-col items-center"> 55 + <div class="text-base-900 dark:text-base-100 text-5xl font-bold sm:text-7xl"> 56 + <NumberFlow value={ss} trend={-1} format={{ minimumIntegerDigits: 2 }} digits={{ 1: { max: 5 } }} /> 57 + </div> 58 + <span class="text-base-400 dark:text-base-500 mt-1 text-xs font-medium uppercase tracking-wider">secs</span> 59 + </div> 60 + </div> 61 + </NumberFlowGroup> 62 + </div> 63 + {/if}
+202
src/routes/p/atmosphereconf.org/DaySchedule.svelte
··· 1 + <script lang="ts"> 2 + import ScheduleEventCell from './ScheduleEventCell.svelte'; 3 + import { 4 + type ScheduleEvent, 5 + type GridData, 6 + SLOT, 7 + linkableTypes, 8 + formatHour, 9 + getDayKey, 10 + getNowGridRow 11 + } from './schedule-utils'; 12 + 13 + let { 14 + grid, 15 + rooms, 16 + dayIndex, 17 + dayEvents, 18 + activeRoom = $bindable(0), 19 + nowVancouverKey, 20 + nowVancouverMinutes, 21 + rsvpUris = new Set(), 22 + dimUnattended = false 23 + }: { 24 + grid: GridData; 25 + rooms: string[]; 26 + dayIndex: number; 27 + dayEvents: ScheduleEvent[]; 28 + activeRoom: number; 29 + nowVancouverKey: string; 30 + nowVancouverMinutes: number; 31 + rsvpUris?: Set<string>; 32 + dimUnattended?: boolean; 33 + } = $props(); 34 + 35 + let dayKey = $derived(dayEvents[0]?.start ? getDayKey(dayEvents[0].start) : ''); 36 + let nowRow = $derived(getNowGridRow(grid, dayKey, nowVancouverKey, nowVancouverMinutes)); 37 + 38 + function isDimmed(event: { did: string; rkey: string; type: string }): boolean { 39 + if (!dimUnattended) return false; 40 + if (event.type === 'info') return true; 41 + const uri = `at://${event.did}/community.lexicon.calendar.event/${event.rkey}`; 42 + return !rsvpUris.has(uri); 43 + } 44 + </script> 45 + 46 + <div 47 + class="border-base-200 dark:border-base-800 bg-base-200 dark:bg-base-950/50 overflow-hidden rounded-xl border" 48 + > 49 + <!-- Mobile: room tabs --> 50 + <div 51 + class="border-base-200 dark:border-base-800 divide-base-200 dark:divide-base-800 flex flex-col divide-y border-b md:hidden" 52 + > 53 + {#each rooms as room, i} 54 + <button 55 + class="flex-1 px-3 py-2 text-xs font-semibold tracking-wide uppercase transition-colors 56 + {activeRoom === i 57 + ? 'bg-accent-100 dark:bg-accent-900/30 text-accent-700 dark:text-accent-300' 58 + : 'text-base-500 dark:text-base-400'}" 59 + onclick={() => (activeRoom = i)} 60 + > 61 + {room} 62 + </button> 63 + {/each} 64 + </div> 65 + 66 + <!-- Mobile: single room view --> 67 + {#each rooms as room, roomIndex} 68 + {@const roomEvents = grid.gridEvents.filter((e) => e.type === 'info' || e.room === room)} 69 + <div class="md:hidden {activeRoom !== roomIndex ? 'hidden' : ''}"> 70 + <div class="flex"> 71 + <div class="border-base-200 dark:border-base-800 w-14 shrink-0 border-r"></div> 72 + <div class="relative grid flex-auto grid-cols-1 grid-rows-1"> 73 + <div 74 + class="divide-base-200/40 dark:divide-base-800/40 col-start-1 col-end-2 row-start-1 grid divide-y" 75 + style={grid.timeGridRows} 76 + > 77 + <div></div> 78 + <div></div> 79 + {#each Array(grid.totalSlots) as _, i} 80 + <div> 81 + {#if (grid.minTime + i * SLOT) % 60 === 0} 82 + <div 83 + class="text-base-400 dark:text-base-500 sticky left-0 z-20 -mt-2 -ml-14 w-14 pr-2 text-right text-[0.65rem] leading-none" 84 + > 85 + {formatHour(grid.minTime + i * SLOT)} 86 + </div> 87 + {/if} 88 + </div> 89 + {/each} 90 + </div> 91 + <ol 92 + class="divide-base-200/40 dark:divide-base-800/40 col-start-1 col-end-2 row-start-1 grid divide-y" 93 + style="grid-template-columns: 1fr; {grid.timeGridRows}" 94 + > 95 + {#each roomEvents as event} 96 + <li 97 + class="relative flex min-h-5 p-0.5 transition-opacity {isDimmed(event) ? (linkableTypes.has(event.type) && event.rkey ? 'opacity-30 hover:opacity-80' : 'opacity-30') : ''}" 98 + style="grid-row: {event.startRow} / span {event.spanRows}; grid-column: 1; z-index: {event.zIndex}" 99 + > 100 + <ScheduleEventCell {event} /> 101 + </li> 102 + {/each} 103 + {#if nowRow} 104 + <li 105 + data-now-line 106 + class="pointer-events-none relative" 107 + style="z-index: 9999; grid-row: {nowRow.row}; grid-column: 1" 108 + > 109 + <div class="absolute inset-x-0 flex items-center" style="top: {nowRow.offsetPercent}%"> 110 + <div class="size-2 rounded-full bg-red-500"></div> 111 + <div class="h-0.5 flex-1 bg-red-500"></div> 112 + </div> 113 + </li> 114 + {/if} 115 + </ol> 116 + </div> 117 + </div> 118 + </div> 119 + {/each} 120 + 121 + <!-- Desktop: full grid --> 122 + <div class="hidden overflow-x-auto md:block"> 123 + <div style="min-width: {rooms.length * 10 + 4}rem"> 124 + <div 125 + class="border-base-200 dark:border-base-800 bg-base-200 dark:bg-base-950/50 flex border-b" 126 + > 127 + <div class="border-base-200 dark:border-base-800 w-14 shrink-0 border-r"></div> 128 + <div 129 + class="divide-base-200 dark:divide-base-800 grid flex-auto divide-x" 130 + style="grid-template-columns: repeat({rooms.length}, minmax(0, 1fr))" 131 + > 132 + {#each rooms as room} 133 + <div 134 + class="text-base-500 dark:text-base-400 px-2 py-2.5 text-center text-xs font-semibold tracking-wide uppercase" 135 + > 136 + {room} 137 + </div> 138 + {/each} 139 + </div> 140 + </div> 141 + 142 + <div class="flex"> 143 + <div class="border-base-200 dark:border-base-800 w-14 shrink-0 border-r"></div> 144 + <div class="relative grid flex-auto grid-cols-1 grid-rows-1"> 145 + <div 146 + class="divide-base-200/40 dark:divide-base-800/40 col-start-1 col-end-2 row-start-1 grid divide-y" 147 + style={grid.timeGridRows} 148 + > 149 + <div></div> 150 + <div></div> 151 + {#each Array(grid.totalSlots) as _, i} 152 + <div> 153 + {#if (grid.minTime + i * SLOT) % 60 === 0} 154 + <div 155 + class="text-base-400 dark:text-base-500 sticky left-0 z-20 -mt-2 -ml-14 w-14 pr-2 text-right text-[0.65rem] leading-none" 156 + > 157 + {formatHour(grid.minTime + i * SLOT)} 158 + </div> 159 + {/if} 160 + </div> 161 + {/each} 162 + </div> 163 + 164 + <div 165 + class="divide-base-200/40 dark:divide-base-800/40 col-start-1 col-end-2 row-start-1 grid grid-rows-1 divide-x" 166 + style="grid-template-columns: repeat({rooms.length}, minmax(0, 1fr))" 167 + > 168 + {#each rooms as _} 169 + <div class="row-span-full"></div> 170 + {/each} 171 + </div> 172 + 173 + <ol 174 + class="divide-base-200/40 dark:divide-base-800/40 col-start-1 col-end-2 row-start-1 grid divide-y" 175 + style="grid-template-columns: repeat({rooms.length}, minmax(0, 1fr)); {grid.timeGridRows}" 176 + > 177 + {#each grid.gridEvents as event} 178 + <li 179 + class="relative flex min-h-5 p-0.5 transition-opacity {isDimmed(event) ? (linkableTypes.has(event.type) && event.rkey ? 'opacity-30 hover:opacity-80' : 'opacity-30') : ''}" 180 + style="grid-row: {event.startRow} / span {event.spanRows}; grid-column: {event.colStart} / span {event.colSpan}; z-index: {event.zIndex}" 181 + > 182 + <ScheduleEventCell {event} /> 183 + </li> 184 + {/each} 185 + {#if nowRow} 186 + <li 187 + data-now-line 188 + class="pointer-events-none relative" 189 + style="z-index: 9999; grid-row: {nowRow.row}; grid-column: 1 / span {rooms.length}" 190 + > 191 + <div class="absolute inset-x-0 flex items-center" style="top: {nowRow.offsetPercent}%"> 192 + <div class="size-2 rounded-full bg-red-500"></div> 193 + <div class="h-0.5 flex-1 bg-red-500"></div> 194 + </div> 195 + </li> 196 + {/if} 197 + </ol> 198 + </div> 199 + </div> 200 + </div> 201 + </div> 202 + </div>
+49
src/routes/p/atmosphereconf.org/ScheduleEventCell.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + type GridEvent, 4 + linkableTypes, 5 + isLightning, 6 + getEventColor, 7 + durationMinutes, 8 + formatTime 9 + } from './schedule-utils'; 10 + 11 + let { event }: { event: GridEvent } = $props(); 12 + </script> 13 + 14 + {#if linkableTypes.has(event.type) && event.rkey} 15 + <a 16 + href="/p/atmosphereconf.org/e/{event.rkey}" 17 + class="flex-1 overflow-hidden rounded-md leading-tight transition-[filter] hover:brightness-95 {getEventColor( 18 + event.type 19 + )} {event.type === 'info' 20 + ? 'flex flex-col items-center justify-center px-2 py-1.5 text-center text-xs' 21 + : ''} {isLightning(event.type) ? 'px-1.5 py-0 text-[0.6rem]' : 'px-2 py-1.5 text-xs'}" 22 + > 23 + <p class="font-semibold {durationMinutes(event.start, event.end) <= 30 ? 'line-clamp-1' : ''}"> 24 + {event.title} 25 + </p> 26 + {#if event.speakers?.length && !isLightning(event.type)} 27 + <p class="mt-0.5 opacity-75">{event.speakers.map((s) => s.name).join(', ')}</p> 28 + {/if} 29 + </a> 30 + {:else} 31 + <div 32 + class="flex-1 overflow-hidden rounded-md leading-tight {getEventColor( 33 + event.type 34 + )} {event.type === 'info' 35 + ? durationMinutes(event.start, event.end) <= 30 36 + ? 'flex items-center justify-center gap-2 px-2 py-0.5 text-center text-xs' 37 + : 'flex flex-col items-center justify-center px-2 py-1.5 text-center text-xs' 38 + : ''} {isLightning(event.type) ? 'px-1.5 py-0 text-[0.6rem]' : 'px-2 py-1.5 text-xs'}" 39 + > 40 + <p class="font-semibold {durationMinutes(event.start, event.end) <= 30 ? 'line-clamp-1' : ''}"> 41 + {event.title} 42 + </p> 43 + {#if event.start} 44 + <p class="{durationMinutes(event.start, event.end) <= 30 ? '' : 'mt-0.5'} opacity-75 shrink-0"> 45 + {formatTime(event.start)}{event.end ? ` – ${formatTime(event.end)}` : ''} 46 + </p> 47 + {/if} 48 + </div> 49 + {/if}
+217
src/routes/p/atmosphereconf.org/schedule-utils.ts
··· 1 + import type { FlatEventRecord } from '$lib/contrail'; 2 + 3 + export interface ScheduleEvent { 4 + rkey: string; 5 + title: string; 6 + type: string; 7 + speakers?: Array<{ id?: string; name: string }>; 8 + start: string; 9 + end?: string; 10 + room?: string; 11 + description?: string; 12 + did: string; 13 + } 14 + 15 + export interface GridEvent extends ScheduleEvent { 16 + startMin: number; 17 + endMin: number; 18 + startRow: number; 19 + spanRows: number; 20 + colStart: number; 21 + colSpan: number; 22 + zIndex: number; 23 + } 24 + 25 + export interface GridData { 26 + gridEvents: GridEvent[]; 27 + minTime: number; 28 + totalSlots: number; 29 + timeGridRows: string; 30 + } 31 + 32 + const TZ = 'America/Vancouver'; 33 + export const SLOT = 10; 34 + export const SLOT_HEIGHT = '1rem'; 35 + 36 + export const linkableTypes = new Set(['workshop', 'presentation', 'lightning-talk', 'panel']); 37 + 38 + export function isLightning(type: string): boolean { 39 + return type === 'lightning-talk'; 40 + } 41 + 42 + export function toVancouverDate(iso: string): Date { 43 + const date = new Date(iso); 44 + const parts = new Intl.DateTimeFormat('en-US', { 45 + timeZone: TZ, 46 + year: 'numeric', 47 + month: '2-digit', 48 + day: '2-digit', 49 + hour: '2-digit', 50 + minute: '2-digit', 51 + hour12: false 52 + }).formatToParts(date); 53 + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0'); 54 + return new Date(get('year'), get('month') - 1, get('day'), get('hour'), get('minute')); 55 + } 56 + 57 + export function isoToMinutes(iso: string): number { 58 + const d = toVancouverDate(iso); 59 + return d.getHours() * 60 + d.getMinutes(); 60 + } 61 + 62 + export function formatTime(iso: string): string { 63 + return new Date(iso).toLocaleTimeString('en-US', { 64 + hour: 'numeric', 65 + minute: '2-digit', 66 + hour12: true 67 + }); 68 + } 69 + 70 + export function formatHour(vancouverMinutes: number): string { 71 + const refDate = new Date('2026-03-28T00:00:00-07:00'); 72 + const ms = refDate.getTime() + vancouverMinutes * 60 * 1000; 73 + const d = new Date(ms); 74 + const h = d.getHours(); 75 + const p = h >= 12 ? 'PM' : 'AM'; 76 + const display = h === 0 ? 12 : h > 12 ? h - 12 : h; 77 + return `${display}${p}`; 78 + } 79 + 80 + export function getDayKey(iso: string): string { 81 + const d = toVancouverDate(iso); 82 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 83 + } 84 + 85 + export function getDayLabel(iso: string): string { 86 + return new Date(iso).toLocaleDateString('en-US', { 87 + timeZone: 'America/Vancouver', 88 + weekday: 'long', 89 + month: 'long', 90 + day: 'numeric' 91 + }); 92 + } 93 + 94 + export function getEventColor(type: string): string { 95 + switch (type) { 96 + case 'info': 97 + return 'bg-base-200 dark:bg-base-800 text-base-700 dark:text-base-300'; 98 + case 'workshop': 99 + return 'bg-cyan-100 dark:bg-cyan-950 text-cyan-900 dark:text-cyan-200'; 100 + case 'lightning-talk': 101 + return 'bg-sky-100 dark:bg-sky-950 text-sky-900 dark:text-sky-200'; 102 + case 'panel': 103 + return 'bg-violet-100 dark:bg-violet-950 text-violet-900 dark:text-violet-200'; 104 + case 'activity': 105 + return 'bg-emerald-100 dark:bg-emerald-950 text-emerald-900 dark:text-emerald-200'; 106 + default: 107 + return 'bg-teal-100 dark:bg-teal-950 text-teal-900 dark:text-teal-200'; 108 + } 109 + } 110 + 111 + export function durationMinutes(start: string, end?: string): number { 112 + if (!end) return 30; 113 + return isoToMinutes(end) - isoToMinutes(start); 114 + } 115 + 116 + export function getScheduleEvents(events: FlatEventRecord[]): ScheduleEvent[] { 117 + return events 118 + .filter( 119 + (e) => 120 + e.additionalData && 121 + (e.additionalData as Record<string, unknown>).isAtmosphereconf 122 + ) 123 + .map((e) => { 124 + const ad = (e.additionalData ?? {}) as Record<string, unknown>; 125 + return { 126 + rkey: e.rkey, 127 + title: e.name, 128 + type: (ad.type as string) ?? 'presentation', 129 + speakers: ad.speakers as Array<{ id?: string; name: string }> | undefined, 130 + start: e.startsAt, 131 + end: e.endsAt, 132 + room: ad.room as string | undefined, 133 + description: e.description, 134 + did: e.did 135 + }; 136 + }) 137 + .sort((a, b) => a.start.localeCompare(b.start)); 138 + } 139 + 140 + export function getRooms(events: ScheduleEvent[]): string[] { 141 + const rooms = new Set<string>(); 142 + for (const e of events) { 143 + if (e.room && e.room !== 'none' && e.type !== 'info') rooms.add(e.room); 144 + } 145 + return [...rooms].sort(); 146 + } 147 + 148 + export function buildGrid(events: ScheduleEvent[], rooms: string[]): GridData { 149 + const parsed = events 150 + .filter((e) => e.start && e.title?.trim() && e.rkey !== 'day-3-slot-3-c') 151 + .map((e) => { 152 + const startMin = isoToMinutes(e.start); 153 + const endMin = e.end ? isoToMinutes(e.end) : startMin + 30; 154 + return { ...e, startMin, endMin }; 155 + }); 156 + 157 + const gridRelevant = parsed.filter((e) => e.type === 'info' || rooms.includes(e.room || '')); 158 + 159 + if (gridRelevant.length === 0) 160 + return { gridEvents: [], minTime: 0, totalSlots: 0, timeGridRows: '' }; 161 + 162 + const minTime = Math.floor(Math.min(...gridRelevant.map((e) => e.startMin)) / SLOT) * SLOT; 163 + const maxTime = Math.ceil(Math.max(...gridRelevant.map((e) => e.endMin)) / SLOT) * SLOT; 164 + const totalSlots = (maxTime - minTime) / SLOT; 165 + 166 + // Remove "Doors Open" events that share a start time with another info event 167 + const infoStartTimes = new Map<number, number>(); 168 + for (const e of gridRelevant) { 169 + if (e.type === 'info') infoStartTimes.set(e.startMin, (infoStartTimes.get(e.startMin) ?? 0) + 1); 170 + } 171 + const filteredRelevant = gridRelevant.filter((e) => { 172 + if (e.type === 'info' && /doors\s*open/i.test(e.title) && (infoStartTimes.get(e.startMin) ?? 0) > 1) return false; 173 + return true; 174 + }); 175 + 176 + // For info events, trim their end time so they don't overlap with the next info event 177 + const infoEvents = filteredRelevant 178 + .filter((e) => e.type === 'info') 179 + .sort((a, b) => a.startMin - b.startMin); 180 + 181 + for (let i = 0; i < infoEvents.length - 1; i++) { 182 + const next = infoEvents[i + 1]; 183 + if (infoEvents[i].endMin > next.startMin) { 184 + infoEvents[i].endMin = next.startMin; 185 + } 186 + } 187 + 188 + const gridEvents = filteredRelevant.map((e) => { 189 + const fullWidth = e.type === 'info'; 190 + return { 191 + ...e, 192 + startRow: Math.round((e.startMin - minTime) / SLOT) + 2, 193 + spanRows: Math.max(1, Math.round((e.endMin - e.startMin) / SLOT)), 194 + colStart: fullWidth ? 1 : rooms.indexOf(e.room!) + 1, 195 + colSpan: fullWidth ? rooms.length : 1, 196 + zIndex: e.startMin 197 + }; 198 + }); 199 + 200 + const timeGridRows = `grid-template-rows: 0.5rem repeat(${totalSlots}, ${SLOT_HEIGHT})`; 201 + 202 + return { gridEvents, minTime, totalSlots, timeGridRows }; 203 + } 204 + 205 + export function getNowGridRow( 206 + grid: GridData, 207 + dayKey: string, 208 + nowVancouverKey: string, 209 + nowVancouverMinutes: number 210 + ): { row: number; offsetPercent: number } | null { 211 + if (grid.totalSlots === 0 || nowVancouverKey !== dayKey) return null; 212 + const exactSlot = (nowVancouverMinutes - grid.minTime) / SLOT; 213 + const row = Math.floor(exactSlot) + 2; 214 + const offsetPercent = (exactSlot - Math.floor(exactSlot)) * 100; 215 + if (row < 2 || row > grid.totalSlots + 2) return null; 216 + return { row, offsetPercent }; 217 + }