Skip to content

Commit b16bfd0

Browse files
Mattclaude
andcommitted
Add API integration with server-side data loading
- Add Vite CORS proxy for API requests - Remove dark mode CSS from theme, header, and sponsor toast - Add "Tap for details" indicator to sponsor toast with hover states - Convert schedule, sponsors, speakers pages to server-side data loading - Fetch data from Copia Events API at build time - Fall back to static JSON data if API unavailable Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2a27c53 commit b16bfd0

10 files changed

Lines changed: 405 additions & 124 deletions

File tree

src/lib/components/Header.svelte

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,6 @@
8989
color: var(--color-primary);
9090
}
9191
92-
@media (prefers-color-scheme: dark) {
93-
.nav-link.active {
94-
color: var(--color-text-light);
95-
}
96-
}
97-
9892
@media (min-width: 768px) {
9993
.header {
10094
padding: var(--space-md) var(--space-xl);

src/lib/components/SponsorToast.svelte

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@
142142
</div>
143143
<span class="sponsor-name">{currentAd.name}</span>
144144
<span class="sponsor-message">{currentAd.message}</span>
145+
<span class="tap-hint">
146+
Tap for details
147+
<svg class="tap-chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
148+
<path d="M6 4L10 8L6 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
149+
</svg>
150+
</span>
145151
</div>
146152

147153
<button
@@ -247,12 +253,16 @@
247253
width: 100%;
248254
text-align: left;
249255
cursor: pointer;
256+
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
250257
}
251258
252-
@media (prefers-color-scheme: dark) {
253-
.toast {
254-
border-color: var(--color-yellow);
255-
}
259+
.toast:hover {
260+
transform: translateY(-2px);
261+
box-shadow: var(--shadow-lg);
262+
}
263+
264+
.toast:active {
265+
transform: translateY(0);
256266
}
257267
258268
.progress-bar {
@@ -280,6 +290,29 @@
280290
gap: 2px;
281291
}
282292
293+
.tap-hint {
294+
display: flex;
295+
align-items: center;
296+
gap: 4px;
297+
margin-top: var(--space-xs);
298+
font-size: var(--text-xs);
299+
color: var(--color-primary);
300+
font-weight: 500;
301+
}
302+
303+
.tap-chevron {
304+
animation: bounce-right 1s ease-in-out infinite;
305+
}
306+
307+
@keyframes bounce-right {
308+
0%, 100% {
309+
transform: translateX(0);
310+
}
311+
50% {
312+
transform: translateX(3px);
313+
}
314+
}
315+
283316
.sponsor-header {
284317
display: flex;
285318
align-items: center;

src/lib/styles/theme.css

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -137,25 +137,3 @@ a {
137137
border: 0;
138138
}
139139

140-
/* Dark Mode */
141-
@media (prefers-color-scheme: dark) {
142-
:root {
143-
--color-white: #1C1C1E;
144-
--color-black: #F5F5F7;
145-
--color-surface: #1C1C1E;
146-
--color-text: #F5F5F7;
147-
--color-text-muted: #98989D;
148-
--color-gray-100: #000000;
149-
--color-gray-200: #2C2C2E;
150-
--color-gray-300: #3A3A3C;
151-
--color-gray-600: #98989D;
152-
--color-gray-800: #D1D1D6;
153-
--color-primary: #4DB8FF; /* Lighter blue for dark mode contrast */
154-
--color-primary-dark: #33A1FD;
155-
--color-primary-light: #7FCBFF;
156-
--color-blue: #4DB8FF;
157-
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
158-
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
159-
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
160-
}
161-
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { PageServerLoad } from './$types';
2+
import scheduleData from '$lib/data/schedule.json';
3+
import adsData from '$lib/data/ads.json';
4+
5+
const API_URL = 'https://manage.copiaevents.com/api/public/events/php-uk-2026';
6+
7+
interface ApiSponsor {
8+
id: string;
9+
name: string;
10+
sponsorship_type: string;
11+
logo_url: string;
12+
has_advert: boolean;
13+
advert?: {
14+
image_url: string;
15+
headline: string;
16+
body: string;
17+
link_url: string;
18+
};
19+
}
20+
21+
interface ApiTrack {
22+
id: string;
23+
name: string;
24+
description: string | null;
25+
color: string;
26+
room_name: string | null;
27+
sort_order: number;
28+
}
29+
30+
interface ApiSpeaker {
31+
id: string;
32+
full_name: string;
33+
job_title: string | null;
34+
headshot_url: string | null;
35+
organisation: string | null;
36+
talk_title: string | null;
37+
talk_abstract: string | null;
38+
}
39+
40+
interface ApiSession {
41+
id: string;
42+
title: string | null;
43+
description: string | null;
44+
speaker: ApiSpeaker;
45+
}
46+
47+
interface ApiSlot {
48+
id: string;
49+
track_id: string | null;
50+
date: string;
51+
start_time: string;
52+
end_time: string;
53+
title: string | null;
54+
slot_type: string;
55+
is_break: boolean;
56+
sessions: ApiSession[];
57+
}
58+
59+
function calculateDuration(startTime: string, endTime: string): number {
60+
const [startH, startM] = startTime.split(':').map(Number);
61+
const [endH, endM] = endTime.split(':').map(Number);
62+
return (endH * 60 + endM) - (startH * 60 + startM);
63+
}
64+
65+
function formatTime(time: string): string {
66+
return time.slice(0, 5);
67+
}
68+
69+
function mapSlotTypeToTag(slotType: string): 'keynote' | 'talk' | 'tutorial' {
70+
if (slotType === 'keynote') return 'keynote';
71+
if (slotType === 'tutorial') return 'tutorial';
72+
return 'talk';
73+
}
74+
75+
function transformApiData(slots: ApiSlot[], apiTracks: ApiTrack[]) {
76+
const transformedTalks: any[] = [];
77+
const transformedBreaks: any[] = [];
78+
79+
const mainTracks = apiTracks.filter(t => !t.name.toLowerCase().includes('tutorial'));
80+
81+
for (const slot of slots) {
82+
const time = formatTime(slot.start_time);
83+
const duration = calculateDuration(slot.start_time, slot.end_time);
84+
85+
if (slot.is_break) {
86+
transformedBreaks.push({
87+
time,
88+
duration,
89+
type: slot.slot_type,
90+
label: slot.title || slot.slot_type.replace('_', ' ')
91+
});
92+
} else if (slot.sessions && slot.sessions.length > 0) {
93+
for (let i = 0; i < slot.sessions.length; i++) {
94+
const session = slot.sessions[i];
95+
let trackId: string;
96+
97+
if (slot.track_id) {
98+
trackId = slot.track_id;
99+
} else if (slot.sessions.length > 1 && mainTracks.length > 0) {
100+
trackId = mainTracks[i % mainTracks.length]?.id || mainTracks[0].id;
101+
} else if (mainTracks.length > 0) {
102+
trackId = mainTracks[0].id;
103+
} else {
104+
trackId = apiTracks[0]?.id || '';
105+
}
106+
107+
const talkTitle = session.speaker?.talk_title || session.title || slot.title || 'TBA';
108+
const talkAbstract = session.speaker?.talk_abstract || session.description || '';
109+
const speakerName = session.speaker?.full_name || 'TBA';
110+
111+
transformedTalks.push({
112+
id: session.id,
113+
track: trackId,
114+
time,
115+
duration,
116+
title: talkTitle,
117+
speaker: speakerName,
118+
speakerPhoto: session.speaker?.headshot_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(speakerName)}&background=018AFC&color=fff&size=200`,
119+
synopsis: talkAbstract,
120+
tag: mapSlotTypeToTag(slot.slot_type),
121+
social: {}
122+
});
123+
}
124+
}
125+
}
126+
127+
return { talks: transformedTalks, breaks: transformedBreaks };
128+
}
129+
130+
export const load: PageServerLoad = async ({ fetch }) => {
131+
try {
132+
const response = await fetch(API_URL);
133+
if (response.ok) {
134+
const data = await response.json();
135+
136+
// Transform tracks
137+
const apiTracks: ApiTrack[] = data.schedule?.tracks || [];
138+
let tracks = scheduleData.tracks;
139+
let talks = scheduleData.talks;
140+
let breaks = scheduleData.breaks;
141+
let sponsorAds = adsData.ads;
142+
143+
if (apiTracks.length > 0) {
144+
tracks = apiTracks
145+
.sort((a, b) => a.sort_order - b.sort_order)
146+
.map((track) => ({
147+
id: track.id,
148+
name: track.name
149+
}));
150+
151+
// Transform schedule slots
152+
const apiSlots: ApiSlot[] = data.schedule?.slots || [];
153+
const transformed = transformApiData(apiSlots, apiTracks);
154+
talks = transformed.talks;
155+
breaks = transformed.breaks;
156+
}
157+
158+
// Transform sponsor ads
159+
const sponsors: ApiSponsor[] = data.sponsors || [];
160+
const apiAds = sponsors
161+
.filter((sponsor) => sponsor.has_advert && sponsor.advert)
162+
.map((sponsor) => ({
163+
id: sponsor.id,
164+
name: sponsor.name,
165+
tier: sponsor.sponsorship_type,
166+
logo: sponsor.logo_url,
167+
message: sponsor.advert!.headline,
168+
fullMessage: sponsor.advert!.body,
169+
image: sponsor.advert!.image_url,
170+
url: sponsor.advert!.link_url
171+
}));
172+
if (apiAds.length > 0) {
173+
sponsorAds = apiAds;
174+
}
175+
176+
return { tracks, talks, breaks, sponsorAds };
177+
}
178+
} catch (error) {
179+
console.error('Failed to fetch schedule data:', error);
180+
}
181+
182+
// Fallback to static data
183+
return {
184+
tracks: scheduleData.tracks,
185+
talks: scheduleData.talks,
186+
breaks: scheduleData.breaks,
187+
sponsorAds: adsData.ads
188+
};
189+
};

0 commit comments

Comments
 (0)