Skip to content

Commit d731885

Browse files
committed
UI improvements
1 parent 4b9444d commit d731885

5 files changed

Lines changed: 347 additions & 22 deletions

File tree

generator.py

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,91 @@ def render_project_modal(project: dict[str, Any]) -> str:
262262
gallery_html = ""
263263
project_gallery = project.get("gallery", [])
264264
if project_gallery:
265-
gallery_items = "".join(
266-
f'<img src="{image}" alt="Gallery image" loading="lazy" decoding="async" '
267-
f'class="w-full h-64 object-cover rounded-none border border-zinc-300 dark:border-zinc-700 cursor-pointer hover:opacity-90 transition-opacity gallery-image" '
268-
f'data-modal-id="{modal_id}">'
269-
for image in project_gallery
270-
)
271-
gallery_html = (
272-
'<div class="mt-4"><h4 class="font-semibold font-mono text-zinc-900 dark:text-zinc-100 mb-2">Gallery</h4>'
273-
f'<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">{gallery_items}</div></div>'
274-
)
265+
slides = []
266+
for index, item in enumerate(project_gallery):
267+
media_type = "image"
268+
src = ""
269+
poster = ""
270+
alt = f"Gallery item {index + 1}"
271+
272+
if isinstance(item, str):
273+
src = item
274+
ext = src.split("?")[0].split("#")[0].split(".")[-1].lower()
275+
if ext in ("mp4", "webm", "ogg", "mov", "m4v"):
276+
media_type = "video"
277+
elif isinstance(item, dict):
278+
media_type = item.get("type", "image")
279+
src = item.get("src") or item.get("url", "")
280+
poster = item.get("poster", "")
281+
alt = item.get("alt") or item.get("caption") or f"Gallery item {index + 1}"
282+
283+
if not src:
284+
continue
285+
286+
if media_type == "video":
287+
poster_attr = f' poster="{poster}"' if poster else ""
288+
media_element = (
289+
f'<video src="{src}"{poster_attr} controls playsinline '
290+
f'class="w-full h-full object-contain bg-black" '
291+
f'preload="metadata"></video>'
292+
)
293+
else:
294+
media_element = (
295+
f'<img src="{src}" alt="{alt}" loading="lazy" decoding="async" '
296+
f'class="w-full h-full object-contain bg-black">'
297+
)
298+
299+
slide_html = (
300+
f'<div class="carousel-slide min-w-full h-64 sm:h-96 flex items-center justify-center flex-shrink-0">'
301+
f'{media_element}'
302+
f'</div>'
303+
)
304+
slides.append(slide_html)
305+
306+
if slides:
307+
slides_html = "".join(slides)
308+
309+
prev_btn = ""
310+
next_btn = ""
311+
indicators = ""
312+
313+
if len(slides) > 1:
314+
prev_btn = (
315+
f'<button class="carousel-prev absolute left-2 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white font-semibold rounded-none p-2 border border-white/20 z-10 transition-all opacity-100 md:opacity-0 md:group-hover/carousel:opacity-100 md:focus:opacity-100 flex items-center justify-center" aria-label="Previous slide">'
316+
f'<ion-icon name="chevron-back" class="text-xl"></ion-icon>'
317+
f'</button>'
318+
)
319+
next_btn = (
320+
f'<button class="carousel-next absolute right-2 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white font-semibold rounded-none p-2 border border-white/20 z-10 transition-all opacity-100 md:opacity-0 md:group-hover/carousel:opacity-100 md:focus:opacity-100 flex items-center justify-center" aria-label="Next slide">'
321+
f'<ion-icon name="chevron-forward" class="text-xl"></ion-icon>'
322+
f'</button>'
323+
)
324+
325+
dots = []
326+
for i in range(len(slides)):
327+
active_class = "bg-accent" if i == 0 else "bg-white/40 hover:bg-white/70"
328+
dot = f'<button class="carousel-dot h-1 w-6 transition-colors {active_class}" data-slide-to="{i}" aria-label="Go to slide {i + 1}"></button>'
329+
dots.append(dot)
330+
331+
indicators = (
332+
f'<div class="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5 z-10">'
333+
f'{"".join(dots)}'
334+
f'</div>'
335+
)
336+
337+
gallery_html = (
338+
f'<div class="mt-4">'
339+
f'<h4 class="font-semibold font-mono text-zinc-900 dark:text-zinc-100 mb-2">Media</h4>'
340+
f'<div class="relative group/carousel w-full overflow-hidden border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900 media-carousel" data-carousel-id="carousel-{modal_id}">'
341+
f'<div class="carousel-slides flex transition-transform duration-300 ease-out" style="transform: translateX(0%);">'
342+
f'{slides_html}'
343+
f'</div>'
344+
f'{prev_btn}'
345+
f'{next_btn}'
346+
f'{indicators}'
347+
f'</div>'
348+
f'</div>'
349+
)
275350

276351
features_html = ""
277352
project_features = project.get("features", [])

index.html

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ <h3 class="text-base font-semibold font-mono text-zinc-900 dark:text-zinc-100 mb
433433
</div>
434434
</div>
435435
</div>
436-
<div id="project-ml-ticker" class="project-card group relative flex flex-col bg-transparent border border-zinc-300 dark:border-zinc-700 rounded-none hover:border-accent dark:hover:border-accent transition-colors overflow-hidden" data-category="aerpaw">
436+
<div id="project-ml-ticker" class="project-card group relative flex flex-col bg-transparent border border-zinc-300 dark:border-zinc-700 rounded-none hover:border-accent dark:hover:border-accent transition-colors overflow-hidden" data-category="other">
437437
<img src="https://placehold.co/400x250/34495E/FFFFFF?text=ML-Ticker" alt="ML-Ticker" loading="lazy" decoding="async" class="w-full h-48 object-cover border-b border-zinc-300 dark:border-zinc-700">
438438
<div class="p-5 flex flex-col flex-grow">
439439
<div class="flex items-start justify-between">
@@ -442,7 +442,7 @@ <h3 class="text-base font-semibold font-mono text-zinc-900 dark:text-zinc-100 mb
442442
<ion-icon name="link" class="text-lg"></ion-icon>
443443
</button>
444444
</div>
445-
<span class="text-xs font-medium font-mono text-zinc-500 dark:text-zinc-400 mb-2 block flex items-center"><ion-icon name="time-outline" class="mr-1"></ion-icon>2026 - Present • AERPAW</span>
445+
<span class="text-xs font-medium font-mono text-zinc-500 dark:text-zinc-400 mb-2 block flex items-center"><ion-icon name="time-outline" class="mr-1"></ion-icon>2026 - Present • Other</span>
446446
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4 flex-grow leading-relaxed">A Python command-line utility for retrieving equity research metrics via the FactSet FQL API.</p>
447447
<div>
448448
<div class="flex flex-wrap mt-3"><span class="inline-flex items-center px-2 py-0.5 rounded-none border border-zinc-300 dark:border-zinc-700 text-xs font-medium font-mono bg-transparent text-zinc-600 dark:text-zinc-400 mr-1.5 mb-1.5">Python</span><span class="inline-flex items-center px-2 py-0.5 rounded-none border border-zinc-300 dark:border-zinc-700 text-xs font-medium font-mono bg-transparent text-zinc-600 dark:text-zinc-400 mr-1.5 mb-1.5">FactSet FQL</span><span class="inline-flex items-center px-2 py-0.5 rounded-none border border-zinc-300 dark:border-zinc-700 text-xs font-medium font-mono bg-transparent text-zinc-600 dark:text-zinc-400 mr-1.5 mb-1.5">Pydantic</span><span class="inline-flex items-center px-2 py-0.5 rounded-none border border-zinc-300 dark:border-zinc-700 text-xs font-medium font-mono bg-transparent text-zinc-600 dark:text-zinc-400 mr-1.5 mb-1.5">openpyxl</span><span class="inline-flex items-center px-2 py-0.5 rounded-none border border-zinc-300 dark:border-zinc-700 text-xs font-medium font-mono bg-transparent text-zinc-600 dark:text-zinc-400 mr-1.5 mb-1.5">CLI</span></div>
@@ -1046,7 +1046,7 @@ <h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">aerpaw
10461046
<h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">ML-Ticker</h2>
10471047
<button class="modal-close-btn text-zinc-500 hover:text-accent dark:hover:text-accent" data-modal-id="modal-project-ml-ticker"></button>
10481048
</div>
1049-
<p class="text-sm text-zinc-600 dark:text-zinc-400 leading-relaxed mb-4">ML-Ticker is a Python command-line tool built for the Merrill Lynch aerpaw to query FactSet FQL API data. It uses a sandboxed Python formula engine to compute post-query custom metrics, features an automatic dependency DAG, validates configuration files via Pydantic, and outputs colorized terminal tables or styled Excel spreadsheets.</p>
1049+
<p class="text-sm text-zinc-600 dark:text-zinc-400 leading-relaxed mb-4">ML-Ticker is a Python command-line tool built for Merrill Lynch to query FactSet FQL API data. It uses a sandboxed Python formula engine to compute post-query custom metrics, features an automatic dependency DAG, validates configuration files via Pydantic, and outputs colorized terminal tables or styled Excel spreadsheets.</p>
10501050

10511051
<div class="mt-4"><h4 class="font-semibold font-mono text-zinc-900 dark:text-zinc-100 mb-2">Features</h4><ul class="space-y-1"><li class="text-sm font-mono text-zinc-600 dark:text-zinc-400 flex items-start"><span class="mr-3"></span><span>FactSet FQL API integration for snapshot and historical timeseries data</span></li><li class="text-sm font-mono text-zinc-600 dark:text-zinc-400 flex items-start"><span class="mr-3"></span><span>Sandboxed Python formula engine for post-query custom metrics</span></li><li class="text-sm font-mono text-zinc-600 dark:text-zinc-400 flex items-start"><span class="mr-3"></span><span>Dependency validation using directed acyclic graphs (DAGs) and Pydantic</span></li><li class="text-sm font-mono text-zinc-600 dark:text-zinc-400 flex items-start"><span class="mr-3"></span><span>Colorized terminal tables and styled multi-sheet Excel spreadsheet export</span></li></ul></div>
10521052
<div class="mt-6 flex gap-3">
@@ -1190,16 +1190,126 @@ <h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">ML-Tic
11901190
selectFilter(initialFilter);
11911191
}
11921192

1193+
// Media Carousel Logic
1194+
const initMediaCarousels = () => {
1195+
const carousels = document.querySelectorAll(".media-carousel");
1196+
carousels.forEach((carousel) => {
1197+
const slidesContainer = carousel.querySelector(".carousel-slides");
1198+
const prevBtn = carousel.querySelector(".carousel-prev");
1199+
const nextBtn = carousel.querySelector(".carousel-next");
1200+
const dots = carousel.querySelectorAll(".carousel-dot");
1201+
1202+
if (!slidesContainer) return;
1203+
1204+
const totalSlides = slidesContainer.children.length;
1205+
let currentIndex = 0;
1206+
1207+
const goToSlide = (index) => {
1208+
if (index < 0) {
1209+
currentIndex = totalSlides - 1;
1210+
} else if (index >= totalSlides) {
1211+
currentIndex = 0;
1212+
} else {
1213+
currentIndex = index;
1214+
}
1215+
1216+
slidesContainer.style.transform = `translateX(-${currentIndex * 100}%)`;
1217+
1218+
// Update dots
1219+
dots.forEach((dot, idx) => {
1220+
if (idx === currentIndex) {
1221+
dot.classList.add("bg-accent");
1222+
dot.classList.remove("bg-white/40", "hover:bg-white/70");
1223+
} else {
1224+
dot.classList.remove("bg-accent");
1225+
dot.classList.add("bg-white/40", "hover:bg-white/70");
1226+
}
1227+
});
1228+
1229+
// Pause videos on inactive slides
1230+
Array.from(slidesContainer.children).forEach((slide, idx) => {
1231+
const video = slide.querySelector("video");
1232+
if (video && idx !== currentIndex) {
1233+
video.pause();
1234+
}
1235+
});
1236+
};
1237+
1238+
// Attach function to the element so we can invoke it on modal actions
1239+
carousel.goToSlide = goToSlide;
1240+
1241+
if (prevBtn) {
1242+
prevBtn.addEventListener("click", (e) => {
1243+
e.stopPropagation();
1244+
goToSlide(currentIndex - 1);
1245+
});
1246+
}
1247+
1248+
if (nextBtn) {
1249+
nextBtn.addEventListener("click", (e) => {
1250+
e.stopPropagation();
1251+
goToSlide(currentIndex + 1);
1252+
});
1253+
}
1254+
1255+
dots.forEach((dot) => {
1256+
dot.addEventListener("click", (e) => {
1257+
e.stopPropagation();
1258+
const slideIndex = parseInt(dot.getAttribute("data-slide-to"), 10);
1259+
if (!isNaN(slideIndex)) {
1260+
goToSlide(slideIndex);
1261+
}
1262+
});
1263+
});
1264+
1265+
// Swipe Support for Touch Screens
1266+
let touchStartX = 0;
1267+
let touchEndX = 0;
1268+
1269+
carousel.addEventListener("touchstart", (e) => {
1270+
touchStartX = e.changedTouches[0].screenX;
1271+
}, { passive: true });
1272+
1273+
carousel.addEventListener("touchend", (e) => {
1274+
touchEndX = e.changedTouches[0].screenX;
1275+
const swipeThreshold = 50;
1276+
if (touchEndX < touchStartX - swipeThreshold) {
1277+
goToSlide(currentIndex + 1);
1278+
} else if (touchEndX > touchStartX + swipeThreshold) {
1279+
goToSlide(currentIndex - 1);
1280+
}
1281+
}, { passive: true });
1282+
});
1283+
};
1284+
1285+
initMediaCarousels();
1286+
11931287
const detailsBtns = document.querySelectorAll(".project-details-btn");
11941288
const closeBtns = document.querySelectorAll(".modal-close-btn");
11951289

1290+
const pauseModalVideos = (modal) => {
1291+
if (modal) {
1292+
modal.querySelectorAll("video").forEach((v) => v.pause());
1293+
}
1294+
};
1295+
1296+
const resetCarousel = (modal) => {
1297+
if (modal) {
1298+
const carousel = modal.querySelector(".media-carousel");
1299+
if (carousel && typeof carousel.goToSlide === "function") {
1300+
carousel.goToSlide(0);
1301+
}
1302+
}
1303+
};
1304+
11961305
detailsBtns.forEach((btn) => {
11971306
btn.addEventListener("click", () => {
11981307
const modalId = btn.getAttribute("data-modal-id");
11991308
const modal = document.getElementById(modalId);
12001309
if (modal) {
12011310
modal.classList.remove("hidden");
12021311
modal.style.display = "flex";
1312+
resetCarousel(modal);
12031313
}
12041314
});
12051315
});
@@ -1211,6 +1321,7 @@ <h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">ML-Tic
12111321
if (modal) {
12121322
modal.classList.add("hidden");
12131323
modal.style.display = "none";
1324+
pauseModalVideos(modal);
12141325
}
12151326
});
12161327
});
@@ -1220,6 +1331,7 @@ <h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">ML-Tic
12201331
if (event.target === modal) {
12211332
modal.classList.add("hidden");
12221333
modal.style.display = "none";
1334+
pauseModalVideos(modal);
12231335
}
12241336
});
12251337
});
@@ -1229,6 +1341,7 @@ <h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">ML-Tic
12291341
document.querySelectorAll('[id^="modal-"]').forEach((modal) => {
12301342
modal.classList.add("hidden");
12311343
modal.style.display = "none";
1344+
pauseModalVideos(modal);
12321345
});
12331346
}
12341347
});
@@ -1519,6 +1632,9 @@ <h2 class="text-2xl font-bold font-mono text-zinc-900 dark:text-zinc-100">ML-Tic
15191632
background-color: rgb(212, 212, 216) !important;
15201633
color: #09090b !important;
15211634
}
1635+
.carousel-slides {
1636+
will-change: transform;
1637+
}
15221638
`;
15231639
document.head.appendChild(style);
15241640
});

particles.js

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
let dpr = getDpr();
9393
let particlesInitialized = false;
9494
let animationFrameId = null;
95+
let stopTimeoutId = null;
9596
let resizeRaf = null;
9697
let isPageVisible = !document.hidden;
9798
let reducedMotion = reducedMotionQuery.matches;
@@ -591,12 +592,18 @@
591592
animationFrameId = null;
592593
}
593594

595+
if (stopTimeoutId !== null) {
596+
clearTimeout(stopTimeoutId);
597+
stopTimeoutId = null;
598+
}
599+
594600
const finishStop = () => {
595601
ctx.globalAlpha = 1;
596602
ctx.clearRect(0, 0, width, height);
597603
canvas.style.opacity = "0";
598604
particlesInitialized = false;
599605
particles.length = 0;
606+
stopTimeoutId = null;
600607
};
601608

602609
if (immediate) {
@@ -605,19 +612,30 @@
605612
}
606613

607614
canvas.style.opacity = "0";
608-
setTimeout(finishStop, CANVAS_FADE_MS);
615+
stopTimeoutId = setTimeout(finishStop, CANVAS_FADE_MS);
609616
};
610617

611618
const startAnimation = () => {
619+
const wasFading = stopTimeoutId !== null;
620+
if (stopTimeoutId !== null) {
621+
clearTimeout(stopTimeoutId);
622+
stopTimeoutId = null;
623+
}
612624
colors = getThemeColors();
613625
updateCanvasDimensions();
614-
initParticles();
615-
mouse.x = -1000;
616-
mouse.y = -1000;
617-
canvas.style.opacity = "0";
618-
requestAnimationFrame(() => {
626+
627+
if (!particlesInitialized || !wasFading) {
628+
initParticles();
629+
mouse.x = -1000;
630+
mouse.y = -1000;
631+
canvas.style.opacity = "0";
632+
requestAnimationFrame(() => {
633+
canvas.style.opacity = "1";
634+
});
635+
} else {
619636
canvas.style.opacity = "1";
620-
});
637+
}
638+
621639
if (reducedMotion) {
622640
drawStatic();
623641
return;

0 commit comments

Comments
 (0)