mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 00:26:31 -06:00
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:
@@ -175,3 +175,18 @@ html.dark {
|
||||
::view-transition-new(page-content) {
|
||||
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>
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import { telemetry } from "$lib/telemetry";
|
||||
import TagChip from "./TagChip.svelte";
|
||||
import TagList from "./TagList.svelte";
|
||||
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 {
|
||||
project: AdminProject & {
|
||||
tags: Array<{ iconSvg?: string; name: string; color?: string }>;
|
||||
tags: ProjectTag[];
|
||||
clockIconSvg?: string;
|
||||
};
|
||||
class?: string;
|
||||
@@ -27,6 +30,60 @@
|
||||
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() {
|
||||
if (clickAction && projectUrl) {
|
||||
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 {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
@@ -65,15 +126,45 @@
|
||||
target={isLink ? "_blank" : undefined}
|
||||
rel={isLink ? "noopener noreferrer" : undefined}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role={isLink ? undefined : "article"}
|
||||
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",
|
||||
isLink &&
|
||||
"group transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800/70",
|
||||
className,
|
||||
"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",
|
||||
{
|
||||
"transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50":
|
||||
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">
|
||||
<h3
|
||||
class={cn(
|
||||
@@ -95,10 +186,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Add link to project search with tag filtering -->
|
||||
<div class="mt-auto flex flex-row-reverse flex-wrap-reverse gap-1">
|
||||
{#each project.tags as tag (tag.name)}
|
||||
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Tags layer -->
|
||||
<TagList
|
||||
tags={project.tags}
|
||||
maxRows={2}
|
||||
class="relative z-10 mt-auto group-hover:opacity-90"
|
||||
style="transition: opacity 300ms ease-in-out;"
|
||||
/>
|
||||
</svelte:element>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let { name, color, iconSvg, href, class: className }: Props = $props();
|
||||
|
||||
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 =
|
||||
"hover:bg-zinc-300/80 dark:hover:bg-zinc-600/50 transition-colors";
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user