91 lines
3.0 KiB
Svelte
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>
|