remdms Claude Opus 4.6 commited on
Commit
e383749
·
1 Parent(s): 75a8f67

feat(mosaic): horizontal scrolling rows, random order, click-through links

Browse files

- Rewrite mosaic as 3 horizontal rows with alternating scroll directions
- Each row scrolls at different speeds for organic feel
- Images preserve aspect ratio, fade in on load, hidden on error
- Click opens story on mediastorm.com (link from nav_config API)
- Hover pauses row, shows tooltip with formatted metadata
- Randomized query suggestions on landing page
- Autofocus on search input
- Stats: "100+ Awards" instead of "4 Emmy Awards"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

frontend/src/app.css CHANGED
@@ -33,3 +33,21 @@ body {
33
  -webkit-font-smoothing: antialiased;
34
  -moz-osx-font-smoothing: grayscale;
35
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  -webkit-font-smoothing: antialiased;
34
  -moz-osx-font-smoothing: grayscale;
35
  }
36
+
37
+ @keyframes scroll-left {
38
+ from {
39
+ transform: translateX(0);
40
+ }
41
+ to {
42
+ transform: translateX(-50%);
43
+ }
44
+ }
45
+
46
+ @keyframes scroll-right {
47
+ from {
48
+ transform: translateX(-50%);
49
+ }
50
+ to {
51
+ transform: translateX(0);
52
+ }
53
+ }
frontend/src/lib/Landing.svelte CHANGED
@@ -10,13 +10,36 @@
10
  let { onsearch }: Props = $props();
11
  let stories: LandingStory[] = $state([]);
12
 
13
- const EXAMPLE_QUERIES = [
14
  "Emmy-winning documentaries",
15
  "Photo essays about Africa",
16
  "Stories about climate change",
17
  "Most awarded documentaries",
 
 
 
 
 
 
 
 
 
 
 
 
18
  ];
19
 
 
 
 
 
 
 
 
 
 
 
 
20
  $effect(() => {
21
  fetchStories().then((s) => (stories = s));
22
  });
@@ -45,8 +68,8 @@
45
  <div class="stat-label">Years</div>
46
  </div>
47
  <div class="stat">
48
- <div class="stat-value">4</div>
49
- <div class="stat-label">Emmy Awards</div>
50
  </div>
51
  </div>
52
 
@@ -81,6 +104,7 @@
81
  rgba(0, 0, 0, 0.4) 100%
82
  );
83
  z-index: 1;
 
84
  }
85
 
86
  .center {
@@ -92,6 +116,11 @@
92
  justify-content: center;
93
  z-index: 2;
94
  padding: 40px 24px;
 
 
 
 
 
95
  }
96
 
97
  .logo {
 
10
  let { onsearch }: Props = $props();
11
  let stories: LandingStory[] = $state([]);
12
 
13
+ const ALL_QUERIES = [
14
  "Emmy-winning documentaries",
15
  "Photo essays about Africa",
16
  "Stories about climate change",
17
  "Most awarded documentaries",
18
+ "War and conflict stories",
19
+ "Multimedia projects from 2010s",
20
+ "Stories about immigration",
21
+ "Sports documentaries",
22
+ "Stories about music",
23
+ "Environmental stories",
24
+ "Stories from the Middle East",
25
+ "Health and medicine stories",
26
+ "Stories about education",
27
+ "Portrait-driven stories",
28
+ "Stories from Asia",
29
+ "Social justice documentaries",
30
  ];
31
 
32
+ function pickRandom(arr: string[], count: number): string[] {
33
+ const copy = [...arr];
34
+ for (let i = copy.length - 1; i > 0; i--) {
35
+ const j = Math.floor(Math.random() * (i + 1));
36
+ [copy[i], copy[j]] = [copy[j], copy[i]];
37
+ }
38
+ return copy.slice(0, count);
39
+ }
40
+
41
+ const EXAMPLE_QUERIES = pickRandom(ALL_QUERIES, 4);
42
+
43
  $effect(() => {
44
  fetchStories().then((s) => (stories = s));
45
  });
 
68
  <div class="stat-label">Years</div>
69
  </div>
70
  <div class="stat">
71
+ <div class="stat-value">100+</div>
72
+ <div class="stat-label">Awards</div>
73
  </div>
74
  </div>
75
 
 
104
  rgba(0, 0, 0, 0.4) 100%
105
  );
106
  z-index: 1;
107
+ pointer-events: none;
108
  }
109
 
110
  .center {
 
116
  justify-content: center;
117
  z-index: 2;
118
  padding: 40px 24px;
119
+ pointer-events: none;
120
+ }
121
+
122
+ .center :global(*) {
123
+ pointer-events: auto;
124
  }
125
 
126
  .logo {
frontend/src/lib/Mosaic.svelte CHANGED
@@ -1,4 +1,5 @@
1
  <script lang="ts">
 
2
  import type { LandingStory } from "./api/client";
3
 
4
  interface Props {
@@ -10,15 +11,34 @@
10
  let hoveredStory: LandingStory | null = $state(null);
11
  let mouseX = $state(0);
12
  let mouseY = $state(0);
13
- let columns: LandingStory[][] = $state([]);
14
- let columnCount = $state(5);
15
- let animationReady = $state(false);
16
- let columnDurations: number[] = $state([]);
17
- let pausedColumn: number | null = $state(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- const SCROLL_SPEED = 15; // px per second
20
- const GAP = 3; // px between images
21
- const TARGET_HEIGHT_MULTIPLIER = 1.5;
 
 
 
22
 
23
  function shuffle(arr: LandingStory[]): LandingStory[] {
24
  const copy = [...arr];
@@ -29,50 +49,105 @@
29
  return copy;
30
  }
31
 
32
- function getColumnCount(): number {
33
- if (typeof window === "undefined") return 5;
34
- if (window.innerWidth <= 640) return 2;
35
- if (window.innerWidth <= 1024) return 3;
36
- return 5;
37
  }
38
 
39
- function distributeToColumns(
40
- items: LandingStory[],
41
- numCols: number,
42
- ): LandingStory[][] {
43
- const cols: LandingStory[][] = Array.from({ length: numCols }, () => []);
44
- const heights: number[] = new Array(numCols).fill(0);
45
-
46
- // Estimate aspect ratio from poster images — assume 3:2 as default
47
- // Actual heights will be measured after render
48
- const estimatedRatio = 2 / 3;
49
- const colWidth = 1; // normalized, all columns equal width
50
-
51
- for (const item of items) {
52
- // Find shortest column
53
- let minIdx = 0;
54
- for (let c = 1; c < numCols; c++) {
55
- if (heights[c] < heights[minIdx]) minIdx = c;
56
- }
57
- cols[minIdx].push(item);
58
- heights[minIdx] += colWidth * estimatedRatio + GAP;
59
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- return cols;
62
- }
63
-
64
- function buildColumns() {
65
- columnCount = getColumnCount();
66
- const shuffled = shuffle(stories);
67
- columns = distributeToColumns(shuffled, columnCount);
68
- animationReady = false;
69
- }
 
 
 
 
 
 
 
 
70
 
71
- function handleMouseEnter(story: LandingStory, colIdx: number, e: MouseEvent) {
72
- hoveredStory = story;
73
- pausedColumn = colIdx;
74
- mouseX = e.clientX;
75
- mouseY = e.clientY;
76
  }
77
 
78
  function handleMouseMove(e: MouseEvent) {
@@ -80,75 +155,40 @@
80
  mouseY = e.clientY;
81
  }
82
 
83
- function handleMouseLeave() {
84
- hoveredStory = null;
85
- pausedColumn = null;
86
- }
87
-
88
- let columnsEl: HTMLDivElement | undefined = $state(undefined);
89
-
90
- function measureAndAnimate() {
91
- if (!columnsEl) return;
92
- const colEls = columnsEl.querySelectorAll<HTMLElement>(".mosaic-column");
93
- const durations: number[] = [];
94
-
95
- for (const colEl of colEls) {
96
- // Total height = doubled content, so half = one set
97
- const halfHeight = colEl.scrollHeight / 2;
98
- const duration = halfHeight / SCROLL_SPEED;
99
- durations.push(duration);
100
- }
101
-
102
- columnDurations = durations;
103
- animationReady = true;
104
- }
105
-
106
- function waitForImages(container: HTMLElement): Promise<void> {
107
- const imgs = container.querySelectorAll<HTMLImageElement>("img");
108
- const promises = Array.from(imgs).map(
109
- (img) =>
110
- new Promise<void>((resolve) => {
111
- if (img.complete) {
112
- resolve();
113
- } else {
114
- img.onload = () => resolve();
115
- img.onerror = () => resolve();
116
- }
117
- }),
118
- );
119
- return Promise.all(promises).then(() => {});
120
- }
121
-
122
- // Build columns when stories change
123
  $effect(() => {
124
- if (stories.length > 0) {
125
- buildColumns();
126
- }
127
- });
128
-
129
- // Measure after render + images loaded
130
- $effect(() => {
131
- if (columns.length > 0 && columnsEl) {
132
- waitForImages(columnsEl).then(() => measureAndAnimate());
133
  }
134
  });
135
 
136
- // Resize handler
137
- let resizeTimer: ReturnType<typeof setTimeout> | undefined;
 
 
 
 
 
 
138
 
139
- $effect(() => {
140
  function onResize() {
141
  clearTimeout(resizeTimer);
142
  resizeTimer = setTimeout(() => {
143
- const newCount = getColumnCount();
144
- if (newCount !== columnCount) {
145
- buildColumns();
146
- }
147
  }, 200);
148
  }
149
 
150
  window.addEventListener("resize", onResize);
 
151
  return () => {
 
152
  window.removeEventListener("resize", onResize);
153
  clearTimeout(resizeTimer);
154
  };
@@ -156,38 +196,7 @@
156
  </script>
157
 
158
  <div class="mosaic" onmousemove={handleMouseMove}>
159
- <div class="mosaic-columns" bind:this={columnsEl}>
160
- {#each columns as colStories, colIdx}
161
- <div
162
- class="mosaic-column"
163
- class:scroll-up={animationReady && colIdx % 2 === 0}
164
- class:scroll-down={animationReady && colIdx % 2 === 1}
165
- class:paused={pausedColumn === colIdx}
166
- style="--scroll-duration: {columnDurations[colIdx] || 30}s;"
167
- >
168
- <!-- Original set -->
169
- {#each colStories as story}
170
- <div
171
- class="mosaic-cell"
172
- onmouseenter={(e) => handleMouseEnter(story, colIdx, e)}
173
- onmouseleave={handleMouseLeave}
174
- >
175
- <img src={story.poster} alt={story.name} />
176
- </div>
177
- {/each}
178
- <!-- Duplicate for seamless loop -->
179
- {#each colStories as story}
180
- <div
181
- class="mosaic-cell"
182
- onmouseenter={(e) => handleMouseEnter(story, colIdx, e)}
183
- onmouseleave={handleMouseLeave}
184
- >
185
- <img src={story.poster} alt={story.name} />
186
- </div>
187
- {/each}
188
- </div>
189
- {/each}
190
- </div>
191
 
192
  {#if hoveredStory}
193
  <div
@@ -195,15 +204,9 @@
195
  style="left: {mouseX + 16}px; top: {mouseY + 16}px;"
196
  >
197
  <div class="tooltip-name">{hoveredStory.name}</div>
198
- <div class="tooltip-meta">
199
- {hoveredStory.year || ""}
200
- {#if hoveredStory.media_type}
201
- &middot; {hoveredStory.media_type}
202
- {/if}
203
- {#if hoveredStory.awards_count > 0}
204
- &middot; {hoveredStory.awards_count} Award{hoveredStory.awards_count > 1 ? "s" : ""}
205
- {/if}
206
- </div>
207
  </div>
208
  {/if}
209
  </div>
@@ -215,64 +218,17 @@
215
  overflow: hidden;
216
  }
217
 
218
- .mosaic-columns {
219
- display: flex;
220
- gap: 3px;
221
- height: 100%;
222
- padding: 0 3px;
223
- }
224
-
225
- .mosaic-column {
226
- flex: 1;
227
  display: flex;
228
  flex-direction: column;
229
  gap: 3px;
230
- will-change: transform;
231
- }
232
-
233
- .mosaic-column.scroll-up {
234
- animation: scroll-up var(--scroll-duration) linear infinite;
235
- }
236
-
237
- .mosaic-column.scroll-down {
238
- animation: scroll-down var(--scroll-duration) linear infinite;
239
- }
240
-
241
- .mosaic-column.paused {
242
- animation-play-state: paused;
243
- }
244
-
245
- @keyframes scroll-up {
246
- from { transform: translateY(0); }
247
- to { transform: translateY(-50%); }
248
- }
249
-
250
- @keyframes scroll-down {
251
- from { transform: translateY(-50%); }
252
- to { transform: translateY(0); }
253
- }
254
-
255
- .mosaic-cell {
256
- overflow: hidden;
257
- flex-shrink: 0;
258
- }
259
-
260
- .mosaic-cell img {
261
- width: 100%;
262
- height: auto;
263
- display: block;
264
- opacity: 0.55;
265
- transition: opacity 0.3s, transform 0.4s;
266
- }
267
-
268
- .mosaic-cell:hover img {
269
- opacity: 0.8;
270
- transform: scale(1.03);
271
  }
272
 
273
  .tooltip {
274
  position: fixed;
275
  z-index: 10;
 
276
  background: rgba(0, 0, 0, 0.7);
277
  backdrop-filter: blur(10px);
278
  border-radius: 8px;
@@ -290,20 +246,4 @@
290
  font-size: 0.65rem;
291
  margin-top: 2px;
292
  }
293
-
294
- @media (max-width: 1024px) {
295
- .mosaic-columns {
296
- gap: 2px;
297
- padding: 0 2px;
298
- }
299
- .mosaic-column { gap: 2px; }
300
- }
301
-
302
- @media (max-width: 640px) {
303
- .mosaic-columns {
304
- gap: 2px;
305
- padding: 0 2px;
306
- }
307
- .mosaic-column { gap: 2px; }
308
- }
309
  </style>
 
1
  <script lang="ts">
2
+ import { onMount } from "svelte";
3
  import type { LandingStory } from "./api/client";
4
 
5
  interface Props {
 
11
  let hoveredStory: LandingStory | null = $state(null);
12
  let mouseX = $state(0);
13
  let mouseY = $state(0);
14
+ let pausedRow: number | null = $state(null);
15
+
16
+ let tooltipMeta = $derived.by(() => {
17
+ if (!hoveredStory) return "";
18
+ return [
19
+ hoveredStory.year ? String(hoveredStory.year) : "",
20
+ hoveredStory.media_type ? formatMediaType(hoveredStory.media_type) : "",
21
+ hoveredStory.awards_count > 0 ? `${hoveredStory.awards_count} Award${hoveredStory.awards_count > 1 ? "s" : ""}` : "",
22
+ ].filter(Boolean).join(" · ");
23
+ });
24
+
25
+ const MEDIA_TYPE_LABELS: Record<string, string> = {
26
+ client_work: "Client Work",
27
+ documentary: "Documentary",
28
+ crisis_guide: "Crisis Guide",
29
+ workshop: "Workshop",
30
+ };
31
+
32
+ function formatMediaType(raw: string): string {
33
+ return MEDIA_TYPE_LABELS[raw] || raw.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
34
+ }
35
 
36
+ let containerEl: HTMLDivElement | undefined;
37
+ let built = false;
38
+
39
+ const ROW_SPEEDS = [12, 35, 20]; // px/s
40
+ const ROW_COUNT = 3;
41
+ const GAP = 3;
42
 
43
  function shuffle(arr: LandingStory[]): LandingStory[] {
44
  const copy = [...arr];
 
49
  return copy;
50
  }
51
 
52
+ function getRowCount(): number {
53
+ return ROW_COUNT;
 
 
 
54
  }
55
 
56
+ function distributeToRows(items: LandingStory[], numRows: number): LandingStory[][] {
57
+ const rows: LandingStory[][] = Array.from({ length: numRows }, () => []);
58
+ for (let i = 0; i < items.length; i++) {
59
+ rows[i % numRows].push(items[i]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
+ return rows;
62
+ }
63
+
64
+ function buildAndRender(storiesList: LandingStory[]) {
65
+ if (!containerEl || storiesList.length === 0) return;
66
+
67
+ const rowHeight = Math.floor(window.innerHeight / ROW_COUNT);
68
+ const numRows = getRowCount();
69
+ const shuffled = shuffle(storiesList);
70
+ const rows = distributeToRows(shuffled, numRows);
71
+
72
+ containerEl.innerHTML = "";
73
+
74
+ rows.forEach((rowStories, rowIdx) => {
75
+ // Outer wrapper — clips overflow, fixed height
76
+ const wrapper = document.createElement("div");
77
+ wrapper.style.cssText = `overflow:hidden;height:${rowHeight}px;flex-shrink:0;`;
78
+
79
+ // Inner track — holds all images, width:max-content, this gets animated
80
+ const track = document.createElement("div");
81
+ track.style.cssText = `display:flex;gap:${GAP}px;width:max-content;will-change:transform;`;
82
+
83
+ // Images twice for seamless loop
84
+ for (let pass = 0; pass < 2; pass++) {
85
+ for (const story of rowStories) {
86
+ const link = document.createElement("a");
87
+ link.href = story.link;
88
+ link.target = "_blank";
89
+ link.rel = "noopener";
90
+ const estimatedWidth = Math.round(rowHeight * 1.5);
91
+ link.style.cssText = `display:block;flex-shrink:0;height:${rowHeight}px;width:${estimatedWidth}px;overflow:hidden;`;
92
+
93
+ const img = document.createElement("img");
94
+ img.src = story.poster;
95
+ img.alt = story.name;
96
+ img.loading = "lazy";
97
+ img.style.cssText = `width:100%;height:100%;object-fit:cover;display:block;opacity:0;transition:opacity 0.5s,transform 0.4s;`;
98
+
99
+ img.onload = () => {
100
+ img.style.opacity = "0.55";
101
+ // Adjust cell width to real aspect ratio
102
+ const realWidth = Math.round(rowHeight * (img.naturalWidth / img.naturalHeight));
103
+ link.style.width = `${realWidth}px`;
104
+ };
105
+ img.onerror = () => { link.style.display = "none"; };
106
+
107
+ link.addEventListener("mouseenter", (e) => {
108
+ hoveredStory = story;
109
+ pausedRow = rowIdx;
110
+ mouseX = e.clientX;
111
+ mouseY = e.clientY;
112
+ if (img.complete && img.naturalWidth > 0) {
113
+ img.style.opacity = "0.8";
114
+ img.style.transform = "scale(1.05)";
115
+ }
116
+ });
117
+
118
+ link.addEventListener("mouseleave", () => {
119
+ hoveredStory = null;
120
+ pausedRow = null;
121
+ if (img.complete && img.naturalWidth > 0) {
122
+ img.style.opacity = "0.55";
123
+ img.style.transform = "";
124
+ }
125
+ });
126
+
127
+ link.appendChild(img);
128
+ track.appendChild(link);
129
+ }
130
+ }
131
 
132
+ wrapper.appendChild(track);
133
+ containerEl!.appendChild(wrapper);
134
+ });
135
+
136
+ // Measure track widths and start animations
137
+ requestAnimationFrame(() => {
138
+ const wrappers = containerEl!.children;
139
+ for (let i = 0; i < wrappers.length; i++) {
140
+ const track = wrappers[i].firstChild as HTMLElement;
141
+ const halfWidth = track.scrollWidth / 2;
142
+ const speed = ROW_SPEEDS[i % ROW_SPEEDS.length];
143
+ const duration = halfWidth / speed;
144
+ const direction = i % 2 === 0 ? "scroll-left" : "scroll-right";
145
+
146
+ track.style.animation = `${direction} ${duration}s linear infinite`;
147
+ }
148
+ });
149
 
150
+ built = true;
 
 
 
 
151
  }
152
 
153
  function handleMouseMove(e: MouseEvent) {
 
155
  mouseY = e.clientY;
156
  }
157
 
158
+ // Pause/unpause row track on hover
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  $effect(() => {
160
+ if (!containerEl) return;
161
+ const wrappers = containerEl.children;
162
+ for (let i = 0; i < wrappers.length; i++) {
163
+ const track = wrappers[i].firstChild as HTMLElement | null;
164
+ if (track) {
165
+ track.style.animationPlayState = pausedRow === i ? "paused" : "running";
166
+ }
 
 
167
  }
168
  });
169
 
170
+ onMount(() => {
171
+ const unsubscribe = $effect.root(() => {
172
+ $effect(() => {
173
+ if (stories.length > 0 && !built) {
174
+ buildAndRender(stories);
175
+ }
176
+ });
177
+ });
178
 
179
+ let resizeTimer: ReturnType<typeof setTimeout>;
180
  function onResize() {
181
  clearTimeout(resizeTimer);
182
  resizeTimer = setTimeout(() => {
183
+ built = false;
184
+ buildAndRender(stories);
 
 
185
  }, 200);
186
  }
187
 
188
  window.addEventListener("resize", onResize);
189
+
190
  return () => {
191
+ unsubscribe();
192
  window.removeEventListener("resize", onResize);
193
  clearTimeout(resizeTimer);
194
  };
 
196
  </script>
197
 
198
  <div class="mosaic" onmousemove={handleMouseMove}>
199
+ <div class="mosaic-rows" bind:this={containerEl}></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  {#if hoveredStory}
202
  <div
 
204
  style="left: {mouseX + 16}px; top: {mouseY + 16}px;"
205
  >
206
  <div class="tooltip-name">{hoveredStory.name}</div>
207
+ {#if tooltipMeta}
208
+ <div class="tooltip-meta">{tooltipMeta}</div>
209
+ {/if}
 
 
 
 
 
 
210
  </div>
211
  {/if}
212
  </div>
 
218
  overflow: hidden;
219
  }
220
 
221
+ .mosaic-rows {
 
 
 
 
 
 
 
 
222
  display: flex;
223
  flex-direction: column;
224
  gap: 3px;
225
+ height: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  }
227
 
228
  .tooltip {
229
  position: fixed;
230
  z-index: 10;
231
+ max-width: 250px;
232
  background: rgba(0, 0, 0, 0.7);
233
  backdrop-filter: blur(10px);
234
  border-radius: 8px;
 
246
  font-size: 0.65rem;
247
  margin-top: 2px;
248
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  </style>
frontend/src/lib/SearchBar.svelte CHANGED
@@ -30,6 +30,7 @@
30
  {placeholder}
31
  {disabled}
32
  onkeydown={handleKeydown}
 
33
  />
34
  <button type="submit" disabled={!value.trim() || disabled} aria-label="Send">
35
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 
30
  {placeholder}
31
  {disabled}
32
  onkeydown={handleKeydown}
33
+ autofocus
34
  />
35
  <button type="submit" disabled={!value.trim() || disabled} aria-label="Send">
36
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
frontend/src/lib/api/client.ts CHANGED
@@ -19,6 +19,7 @@ export interface LandingStory {
19
  year: number;
20
  media_type: string;
21
  awards_count: number;
 
22
  }
23
 
24
  export async function fetchStories(): Promise<LandingStory[]> {
 
19
  year: number;
20
  media_type: string;
21
  awards_count: number;
22
+ link: string;
23
  }
24
 
25
  export async function fetchStories(): Promise<LandingStory[]> {
src/mediastorm/api.py CHANGED
@@ -194,7 +194,12 @@ async def health():
194
 
195
  @app.get("/api/stories")
196
  async def stories():
197
- return {"stories": _landing_stories}
 
 
 
 
 
198
 
199
 
200
  @app.post("/api/chat")
 
194
 
195
  @app.get("/api/stories")
196
  async def stories():
197
+ enriched = []
198
+ for s in _landing_stories:
199
+ entry = dict(s)
200
+ entry["link"] = _link_lookup.get(s["uid"], f"https://www.mediastorm.com/{s['slug']}")
201
+ enriched.append(entry)
202
+ return {"stories": enriched}
203
 
204
 
205
  @app.post("/api/chat")