Spaces:
Sleeping
Sleeping
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 +18 -0
- frontend/src/lib/Landing.svelte +32 -3
- frontend/src/lib/Mosaic.svelte +150 -210
- frontend/src/lib/SearchBar.svelte +1 -0
- frontend/src/lib/api/client.ts +1 -0
- src/mediastorm/api.py +6 -1
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
|
| 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">
|
| 49 |
-
<div class="stat-label">
|
| 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
|
| 14 |
-
|
| 15 |
-
let
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
function shuffle(arr: LandingStory[]): LandingStory[] {
|
| 24 |
const copy = [...arr];
|
|
@@ -29,50 +49,105 @@
|
|
| 29 |
return copy;
|
| 30 |
}
|
| 31 |
|
| 32 |
-
function
|
| 33 |
-
|
| 34 |
-
if (window.innerWidth <= 640) return 2;
|
| 35 |
-
if (window.innerWidth <= 1024) return 3;
|
| 36 |
-
return 5;
|
| 37 |
}
|
| 38 |
|
| 39 |
-
function
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 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 |
-
|
| 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 (
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
if (columns.length > 0 && columnsEl) {
|
| 132 |
-
waitForImages(columnsEl).then(() => measureAndAnimate());
|
| 133 |
}
|
| 134 |
});
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
function onResize() {
|
| 141 |
clearTimeout(resizeTimer);
|
| 142 |
resizeTimer = setTimeout(() => {
|
| 143 |
-
|
| 144 |
-
|
| 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-
|
| 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 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
· {hoveredStory.media_type}
|
| 202 |
-
{/if}
|
| 203 |
-
{#if hoveredStory.awards_count > 0}
|
| 204 |
-
· {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-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|