Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
95 lines
3.0 KiB
Svelte
95 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>
|