Files
jellytau/src/lib/components/library/MosaicTile.svelte
T
dtourolle 3363ff7f08 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	scripts/extract-traces.test.ts
2026-08-16 00:51:46 +02:00

91 lines
3.0 KiB
Svelte

<!--
One tile of a mosaic: artwork at an exact pixel box, with its label written
over the bottom of the image rather than beneath it.
The label lives on the artwork on purpose — a caption below would add height
outside the box the layout computed, and a row whose captions wrap to two
lines would no longer line up with its neighbours. Keeping everything inside
the box is what lets `layoutMosaic` own the geometry completely.
TRACES: UR-075 | DR-174
-->
<script lang="ts">
import type { Snippet } from "svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
label: string;
width: number;
height: number;
/** Item whose Primary image is the artwork. Omit for an icon-only tile. */
itemId?: string;
imageTag?: string | null;
/** Drawn instead of artwork — favourites tiles have no image of their own. */
icon?: Snippet;
/** Tints an icon-only tile so it reads as a destination, not a broken image. */
accent?: boolean;
onclick?: () => void;
/**
* Reports the artwork's true aspect ratio once decoded, so the grid can
* re-pack against the shape the image actually has.
*/
onRatio?: (ratio: number) => void;
}
let {
label,
width,
height,
itemId,
imageTag,
icon,
accent = false,
onclick,
onRatio,
}: Props = $props();
// Request an image comfortably larger than the box so a wide tile is not
// upscaled, without refetching every time the container resizes (CachedImage
// keys its fetch on the item, not on this number).
const REQUEST_WIDTH = 480;
</script>
<button
type="button"
{onclick}
aria-label={label}
class="group/tile relative overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md
transition-transform duration-200 hover:z-10 hover:scale-[1.03] hover:shadow-2xl
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-jellyfin)]"
style="width: {width}px; height: {height}px;"
>
{#if icon}
<div
class="absolute inset-0 flex items-center justify-center
{accent
? 'bg-gradient-to-br from-[var(--color-jellyfin)]/40 to-[var(--color-jellyfin)]/5'
: 'bg-[var(--color-surface)]'}"
>
{@render icon()}
</div>
{:else if itemId}
<CachedImage
{itemId}
imageType="Primary"
tag={imageTag}
maxWidth={REQUEST_WIDTH}
alt={label}
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover/tile:scale-105"
onNaturalSize={(w, h) => onRatio?.(w / h)}
/>
{/if}
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
{label}
</p>
</div>
</button>