feat: add responsive tag overflow with dynamic video/image backgrounds

- TagList component with binary search overflow calculation and +N pill
- ProjectCard background media layer with gradient mask and hover effects
- Random video/image selection per card with play on hover
- Responsive tag wrapping with ResizeObserver
This commit is contained in:
2026-01-14 21:13:26 -06:00
parent 59b2f15df7
commit 39a4e702fd
5 changed files with 286 additions and 14 deletions
+15
View File
@@ -175,3 +175,18 @@ html.dark {
::view-transition-new(page-content) { ::view-transition-new(page-content) {
animation: vt-slide-from-right 250ms cubic-bezier(0.4, 0, 0.2, 1) both; animation: vt-slide-from-right 250ms cubic-bezier(0.4, 0, 0.2, 1) both;
} }
/* Media mask for project card background images/videos - fades from transparent (left) to solid (right) */
.media-mask-fade-left {
mask-image: linear-gradient(
to right,
transparent 0%,
/* Non-linear stops to help breakup the gradient better */
rgba(0, 0, 0, 0.08) 12%,
rgba(0, 0, 0, 0.2) 22%,
rgba(0, 0, 0, 0.4) 32%,
rgba(0, 0, 0, 0.65) 42%,
rgba(0, 0, 0, 0.85) 52%,
black 65%
);
}
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils";
interface Props {
count: number;
hiddenTagNames: string[];
class?: string;
}
let { count, hiddenTagNames, class: className }: Props = $props();
const tooltipText = $derived(hiddenTagNames.join(", "));
</script>
<span
class={cn(
"inline-flex items-center rounded-sm bg-zinc-200/50 dark:bg-zinc-600/30 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-500 dark:text-zinc-400",
className,
)}
title={tooltipText}
>
+{count}
</span>
+105 -13
View File
@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { telemetry } from "$lib/telemetry"; import { telemetry } from "$lib/telemetry";
import TagChip from "./TagChip.svelte"; import TagList from "./TagList.svelte";
import type { AdminProject } from "$lib/admin-types"; import type { AdminProject } from "$lib/admin-types";
// Extended tag type with icon SVG for display
type ProjectTag = { iconSvg?: string; name: string; color?: string };
interface Props { interface Props {
project: AdminProject & { project: AdminProject & {
tags: Array<{ iconSvg?: string; name: string; color?: string }>; tags: ProjectTag[];
clockIconSvg?: string; clockIconSvg?: string;
}; };
class?: string; class?: string;
@@ -27,6 +30,60 @@
project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null, project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null,
); );
// Random seed generated once per component instance (changes on each page load)
const randomSeed = Math.floor(Math.random() * 1000);
// Randomly decide if this card shows video or image (~60% video)
const isVideo = randomSeed % 10 < 6;
// Sample video URLs
const sampleVideos = [
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/samples/elephants.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/samples/sea-turtle.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/dog.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/ski_jump.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/snow_horses.mp4",
];
const videoUrl = sampleVideos[randomSeed % sampleVideos.length];
// Randomized aspect ratios for images: [width, height]
const aspectRatios: [number, number][] = [
[400, 300], // 4:3 landscape
[300, 400], // 3:4 portrait
[400, 400], // 1:1 square
[480, 270], // 16:9 landscape
[270, 480], // 9:16 portrait
[400, 240], // 5:3 wide landscape
[240, 400], // 3:5 tall portrait
];
const aspectIndex = randomSeed % aspectRatios.length;
const [imgWidth, imgHeight] = aspectRatios[aspectIndex];
const imageUrl = `https://picsum.photos/seed/${randomSeed}/${imgWidth}/${imgHeight}`;
// Video element reference for play/pause control
let videoElement: HTMLVideoElement | null = $state(null);
function handleMouseEnter() {
if (videoElement) {
videoElement.play();
}
}
function handleMouseLeave() {
if (videoElement) {
videoElement.pause();
}
}
function handleClick() { function handleClick() {
if (clickAction && projectUrl) { if (clickAction && projectUrl) {
telemetry.track({ telemetry.track({
@@ -41,6 +98,10 @@
} }
} }
// Shared classes for background media (image/video)
const mediaBaseClasses =
"media-mask-fade-left absolute right-0 top-0 h-full w-3/4 object-cover object-center";
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -65,15 +126,45 @@
target={isLink ? "_blank" : undefined} target={isLink ? "_blank" : undefined}
rel={isLink ? "noopener noreferrer" : undefined} rel={isLink ? "noopener noreferrer" : undefined}
onclick={handleClick} onclick={handleClick}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role={isLink ? undefined : "article"} role={isLink ? undefined : "article"}
class={cn( class={cn(
"flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3", "group relative flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 overflow-hidden",
isLink && {
"group transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800/70", "transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50":
className, isLink,
className: true,
},
)} )}
> >
<div class="flex flex-col gap-1"> <!-- Background media layer -->
<div
class="pointer-events-none absolute inset-0 opacity-25 group-hover:opacity-40"
style="transition: opacity 300ms ease-in-out;"
aria-hidden="true"
>
{#if isVideo}
<video
bind:this={videoElement}
src={videoUrl}
class={cn(mediaBaseClasses, "grayscale group-hover:grayscale-0")}
style="transition: filter 300ms ease-in-out;"
muted
loop
playsinline
preload="metadata"
></video>
{:else}
<img src={imageUrl} alt="" class={mediaBaseClasses} loading="lazy" />
{/if}
</div>
<!-- Content layer -->
<div
class="relative z-10 flex flex-col gap-1 group-hover:opacity-80"
style="transition: opacity 300ms ease-in-out;"
>
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h3 <h3
class={cn( class={cn(
@@ -95,10 +186,11 @@
</p> </p>
</div> </div>
<!-- TODO: Add link to project search with tag filtering --> <!-- Tags layer -->
<div class="mt-auto flex flex-row-reverse flex-wrap-reverse gap-1"> <TagList
{#each project.tags as tag (tag.name)} tags={project.tags}
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} /> maxRows={2}
{/each} class="relative z-10 mt-auto group-hover:opacity-90"
</div> style="transition: opacity 300ms ease-in-out;"
/>
</svelte:element> </svelte:element>
+1 -1
View File
@@ -12,7 +12,7 @@
let { name, color, iconSvg, href, class: className }: Props = $props(); let { name, color, iconSvg, href, class: className }: Props = $props();
const baseClasses = const baseClasses =
"inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"; "inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3 shadow-sm";
const linkClasses = const linkClasses =
"hover:bg-zinc-300/80 dark:hover:bg-zinc-600/50 transition-colors"; "hover:bg-zinc-300/80 dark:hover:bg-zinc-600/50 transition-colors";
</script> </script>
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import TagChip from "./TagChip.svelte";
import OverflowPill from "./OverflowPill.svelte";
export type Tag = { iconSvg?: string; name: string; color?: string };
interface Props {
tags: Tag[];
maxRows?: number;
class?: string;
style?: string;
}
let { tags, maxRows = 2, class: className, style }: Props = $props();
// Tag overflow detection
let tagsContainer: HTMLDivElement | null = $state(null);
let visibleTagCount: number | null = $state(null); // null = show all
// Derived visible and hidden tags based on overflow calculation
const effectiveVisibleCount = $derived(visibleTagCount ?? tags.length);
const visibleTags = $derived(tags.slice(0, effectiveVisibleCount));
const hiddenTags = $derived(tags.slice(effectiveVisibleCount));
const hiddenTagNames = $derived(hiddenTags.map((t) => t.name));
// Measure and calculate tag overflow
function calculateOverflow() {
if (!tagsContainer || tags.length === 0) return;
const container = tagsContainer;
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) return;
// Get computed gap from container
const containerStyle = getComputedStyle(container);
const gap = parseFloat(containerStyle.gap) || 4;
// Measure first tag to get line height
const firstTag = children[0];
if (!firstTag) return;
const lineHeight = firstTag.offsetHeight;
const maxHeight = lineHeight * maxRows + gap * (maxRows - 1);
// If container fits within max height, show all tags
if (container.scrollHeight <= maxHeight + 1) {
visibleTagCount = null; // Show all
return;
}
// Binary search to find optimal visible count
let low = 1;
let high = tags.length;
let result = 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
// Temporarily show only 'mid' tags to measure
visibleTagCount = mid;
// Force reflow to get accurate measurement
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
container.offsetHeight;
// Measure based on children positions
const currentHiddenCount = tags.length - mid;
const visibleChildren = Array.from(container.children).slice(
0,
mid + (currentHiddenCount > 0 ? 1 : 0),
) as HTMLElement[];
if (visibleChildren.length === 0) {
low = mid + 1;
continue;
}
// Calculate actual height based on child positions
let minTop = Infinity;
let maxBottom = 0;
for (const child of visibleChildren) {
const rect = child.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top;
const relativeBottom = rect.bottom - containerRect.top;
minTop = Math.min(minTop, relativeTop);
maxBottom = Math.max(maxBottom, relativeBottom);
}
const actualHeight = maxBottom - minTop;
if (actualHeight <= maxHeight + 1) {
result = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
// Set final visible count
if (result < tags.length) {
visibleTagCount = result;
} else {
visibleTagCount = null; // Show all
}
}
// Run overflow calculation after mount and on resize
$effect(() => {
if (!tagsContainer) return;
// Initial calculation
calculateOverflow();
// Set up resize observer
const resizeObserver = new ResizeObserver(() => {
// Reset to show all tags first, then recalculate
visibleTagCount = null;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
calculateOverflow();
});
});
resizeObserver.observe(tagsContainer);
return () => {
resizeObserver.disconnect();
};
});
</script>
<div
bind:this={tagsContainer}
class="flex flex-row-reverse flex-wrap-reverse gap-1 {className}"
{style}
>
{#each visibleTags as tag (tag.name)}
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
{/each}
{#if hiddenTags.length > 0}
<OverflowPill count={hiddenTags.length} {hiddenTagNames} />
{/if}
</div>