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) {
|
::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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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