release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs: - Theme moves from tailwind.config.js into app.css as `@theme inline`, mapping the same FabledSword tokens. tailwind.config.js is gone. - PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since Tailwind 4 prefixes through Lightning CSS. - Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none -> outline-hidden, focus-visible:outline -> outline-solid, shadow -> shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare `rounded` stays: v4 keeps it at 0.25rem, as before. - Three v3 preflight defaults kept in a base layer so nothing changes on screen: gray-200 default border colour, gray-400 placeholder text and the pointer cursor on buttons. - The unused class-based dark variant is not carried over; no template uses `dark:`. Clears the five high and two moderate npm audit findings that came in through Tailwind 3 (braces, chokidar, micromatch, fast-glob, postcss-selector-parser). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
5.5 KiB
Svelte
191 lines
5.5 KiB
Svelte
<script lang="ts" module>
|
|
export type DiscoverCardKind = 'artist' | 'album' | 'track';
|
|
// 'snoozed' is a transient state the card flips to in place after the user
|
|
// parks it, so the disappearance is legible and undoable rather than a card
|
|
// silently vanishing from under the cursor (rule #24). The row is gone on
|
|
// the next refetch; the persistent way back is the snoozed list.
|
|
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
|
|
|
|
let {
|
|
kind,
|
|
title,
|
|
subtitle,
|
|
imageUrl,
|
|
state,
|
|
attribution,
|
|
onRequest,
|
|
onSnooze,
|
|
onUnsnooze,
|
|
}: {
|
|
kind: DiscoverCardKind;
|
|
title: string;
|
|
subtitle?: string;
|
|
imageUrl?: string;
|
|
state: DiscoverCardState;
|
|
attribution?: string;
|
|
onRequest?: () => void;
|
|
// Omit both to get a card with no snooze affordance — the Lidarr search
|
|
// results reuse this component and have nothing to park.
|
|
onSnooze?: () => void;
|
|
onUnsnooze?: () => void;
|
|
} = $props();
|
|
|
|
const FallbackIcon = $derived(
|
|
kind === 'artist' ? Disc3 : kind === 'album' ? Album : Music2
|
|
);
|
|
|
|
function handleRequest() {
|
|
if (state === 'requestable') onRequest?.();
|
|
}
|
|
</script>
|
|
|
|
<article
|
|
class="card rounded-lg border border-border bg-surface p-3"
|
|
data-state={state}
|
|
>
|
|
<div
|
|
class="art flex aspect-square w-full items-center justify-center overflow-hidden rounded-md bg-surface-hover"
|
|
>
|
|
{#if imageUrl}
|
|
<img
|
|
src={imageUrl}
|
|
alt=""
|
|
loading="lazy"
|
|
class="h-full w-full object-cover"
|
|
/>
|
|
{:else}
|
|
<FallbackIcon size={32} strokeWidth={1} class="text-text-muted" />
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="text mt-3">
|
|
<div class="title text-base font-medium text-text-primary">{title}</div>
|
|
{#if subtitle}
|
|
<div class="subtitle text-sm text-text-secondary">{subtitle}</div>
|
|
{/if}
|
|
{#if attribution}
|
|
<div class="attribution text-xs italic text-text-secondary" data-testid="attribution">
|
|
{attribution}
|
|
</div>
|
|
{/if}
|
|
<div class="badge-row" data-testid="badge-row">
|
|
{#if state === 'kept'}
|
|
<span class="kept-pill" role="status">Kept</span>
|
|
{:else if state === 'snoozed'}
|
|
<span class="snoozed-pill" role="status">Not right now</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="actions pt-3" data-testid="actions">
|
|
{#if state === 'snoozed'}
|
|
<button
|
|
type="button"
|
|
aria-label={`Bring ${title} back`}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
|
|
onclick={() => onUnsnooze?.()}
|
|
>
|
|
Undo
|
|
</button>
|
|
{:else if state === 'requestable'}
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
aria-label={`Request ${title}`}
|
|
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
|
|
onclick={handleRequest}
|
|
>
|
|
<Plus size={16} strokeWidth={1} /> Request
|
|
</button>
|
|
{#if onSnooze}
|
|
<!--
|
|
Icon-only to keep Request unambiguously the primary action, with
|
|
the intent carried by the accessible name. "Not right now" is the
|
|
whole point of the wording: this parks a suggestion, it does not
|
|
record an opinion about the artist (rule #101).
|
|
-->
|
|
<button
|
|
type="button"
|
|
aria-label={`Not right now — hide ${title} for a while`}
|
|
title="Not right now"
|
|
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
|
|
onclick={() => onSnooze?.()}
|
|
>
|
|
<Clock size={16} strokeWidth={1} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else if state === 'kept'}
|
|
<button
|
|
type="button"
|
|
disabled
|
|
aria-label={`${title} is already in library`}
|
|
onclick={handleRequest}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted"
|
|
>
|
|
In library
|
|
</button>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
disabled
|
|
aria-label={`${title} already requested`}
|
|
onclick={handleRequest}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted"
|
|
>
|
|
Requested
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</article>
|
|
|
|
<style>
|
|
.card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.badge-row {
|
|
min-height: 22px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.actions {
|
|
margin-top: auto;
|
|
}
|
|
.kept-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
font-size: 11px;
|
|
line-height: 14px;
|
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
|
color: var(--fs-accent-fg);
|
|
}
|
|
/* Muted rather than accented: a parked card should recede, not compete
|
|
with the live suggestions around it. Same geometry as .kept-pill so the
|
|
two read as one component in different states. */
|
|
.snoozed-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
font-size: 11px;
|
|
line-height: 14px;
|
|
background: var(--fs-slate);
|
|
color: var(--fs-vellum);
|
|
}
|
|
.card[data-state='snoozed'] {
|
|
opacity: 0.6;
|
|
}
|
|
</style>
|