Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
8.6 KiB
Svelte
213 lines
8.6 KiB
Svelte
<script lang="ts">
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createSuggestionsQuery,
|
|
createSnoozesQuery,
|
|
snoozeSuggestion,
|
|
unsnoozeSuggestion
|
|
} from '#lib/api/suggestions.js';
|
|
import { createRequest } from '#lib/api/requests.js';
|
|
import { qk } from '#lib/api/queries.js';
|
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
|
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
|
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '#lib/api/types.js';
|
|
|
|
const client = useQueryClient();
|
|
const queryStore = createSuggestionsQuery();
|
|
const query = $derived($queryStore);
|
|
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
|
|
|
|
const snoozeStore = createSnoozesQuery();
|
|
const snoozeQuery = $derived($snoozeStore);
|
|
const snoozes = $derived((snoozeQuery.data ?? []) as SuggestionSnooze[]);
|
|
|
|
// Track MBIDs the user just requested so the card flips immediately.
|
|
let optimisticRequested = $state(new Set<string>());
|
|
// Snoozed-just-now MBIDs. These keep their card in place showing an Undo,
|
|
// rather than yanking it out of the grid under the cursor — the card is
|
|
// gone on the next refetch, and the snoozed list below is the way back
|
|
// after that.
|
|
let optimisticSnoozed = $state(new Set<string>());
|
|
|
|
function visible(s: ArtistSuggestion): boolean {
|
|
return !optimisticRequested.has(s.mbid);
|
|
}
|
|
|
|
function cardState(s: ArtistSuggestion): 'requestable' | 'snoozed' {
|
|
return optimisticSnoozed.has(s.mbid) ? 'snoozed' : 'requestable';
|
|
}
|
|
|
|
function withMbid(set: Set<string>, mbid: string, present: boolean): Set<string> {
|
|
const next = new Set(set);
|
|
if (present) next.add(mbid);
|
|
else next.delete(mbid);
|
|
return next;
|
|
}
|
|
|
|
// "Matches your taste in shoegaze and dream pop." — the reason line when the
|
|
// candidate's own tags overlap the taste profile (#2377). Preferred over seed
|
|
// attribution because it describes the MUSIC ("sounds like what you like")
|
|
// rather than the graph ("adjacent to something you played"), which is the
|
|
// whole point of slice 6. Falls back when there are no matched tags, which is
|
|
// the common case: coverage is partial by nature (#2376).
|
|
function reasonText(s: ArtistSuggestion): string {
|
|
const tags = s.matched_tags ?? [];
|
|
if (tags.length === 0) return attributionText(s.attribution);
|
|
if (tags.length === 1) return `Matches your taste in ${tags[0]}.`;
|
|
if (tags.length === 2) return `Matches your taste in ${tags[0]} and ${tags[1]}.`;
|
|
return `Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}.`;
|
|
}
|
|
|
|
function attributionText(attribution: SeedContribution[]): string {
|
|
if (attribution.length === 0) return '';
|
|
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
|
|
const phrases = attribution.map((s) => `${verb(s)} ${s.name}`);
|
|
if (phrases.length === 1) {
|
|
return `Because you ${phrases[0]}.`;
|
|
}
|
|
if (phrases.length === 2) {
|
|
return `Because you ${phrases[0]} and ${phrases[1]}.`;
|
|
}
|
|
// 3 with Oxford comma
|
|
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
|
|
}
|
|
|
|
// "in 3 months" / "in 12 days" — a relative phrase, because the exact
|
|
// calendar date of a 90-day snooze is noise the user never asked for.
|
|
function returnsIn(snoozedUntil: string): string {
|
|
const ms = new Date(snoozedUntil).getTime() - Date.now();
|
|
if (!Number.isFinite(ms) || ms <= 0) return 'shortly';
|
|
const days = Math.round(ms / 86_400_000);
|
|
if (days < 1) return 'today';
|
|
if (days === 1) return 'tomorrow';
|
|
// The 30 must not exceed the divisor below, or the singular "in about a
|
|
// month" is unreachable — a rounded month count of 1 needs 15..44 days,
|
|
// and any higher threshold sends all of those down the days branch. This
|
|
// read 45 and the singular case was dead code (caught by the Android
|
|
// unit test for the same logic).
|
|
if (days < 30) return `in ${days} days`;
|
|
const months = Math.round(days / 30);
|
|
return months === 1 ? 'in about a month' : `in about ${months} months`;
|
|
}
|
|
|
|
async function onRequest(s: ArtistSuggestion) {
|
|
try {
|
|
await createRequest({
|
|
kind: 'artist',
|
|
lidarr_artist_mbid: s.mbid,
|
|
artist_name: s.name
|
|
});
|
|
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
|
|
// The server-side filter hides this candidate on next refetch.
|
|
await client.invalidateQueries({ queryKey: qk.suggestions() });
|
|
} catch {
|
|
// Swallow for v1; the SPA will refetch on next mount and the card
|
|
// stays requestable so the user can retry.
|
|
}
|
|
}
|
|
|
|
async function onSnooze(s: ArtistSuggestion) {
|
|
// Flip first so the tap feels instant, then reconcile. On failure the
|
|
// card goes back to requestable and says so — a snooze that silently
|
|
// did nothing would leave the user tapping it again.
|
|
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, true);
|
|
try {
|
|
await snoozeSuggestion(s.mbid, s.name);
|
|
await client.invalidateQueries({ queryKey: qk.suggestionSnoozes() });
|
|
} catch {
|
|
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, false);
|
|
pushToast(`Couldn't hide ${s.name}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function onUnsnooze(mbid: string, name: string) {
|
|
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, false);
|
|
try {
|
|
await unsnoozeSuggestion(mbid);
|
|
} catch (e) {
|
|
// 404 means it wasn't snoozed after all — the user's intended end
|
|
// state, so it isn't an error worth showing them.
|
|
if ((e as { status?: number })?.status !== 404) {
|
|
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, true);
|
|
pushToast(`Couldn't bring ${name} back`, 'error');
|
|
return;
|
|
}
|
|
}
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.suggestionSnoozes() }),
|
|
client.invalidateQueries({ queryKey: qk.suggestions() })
|
|
]);
|
|
}
|
|
</script>
|
|
|
|
<div>
|
|
<header class="mb-4 space-y-1">
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Suggested for you</h2>
|
|
<p class="text-text-secondary">Out-of-library artists drawn from what you've liked and played.</p>
|
|
</header>
|
|
|
|
{#if !query.isPending && suggestions.length === 0}
|
|
<!--
|
|
An empty deck used to mean one thing — no listening signal yet. With
|
|
snoozing it can also mean "you parked them all", and telling that user to
|
|
go listen to something would be wrong advice.
|
|
-->
|
|
<p class="text-text-secondary">
|
|
{snoozes.length === 0
|
|
? 'Listen to something or like an artist to start getting suggestions.'
|
|
: "Nothing new right now — the artists you've parked are below."}
|
|
</p>
|
|
{:else if suggestions.length > 0}
|
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
{#each suggestions.filter(visible) as s (s.mbid)}
|
|
<DiscoverResultCard
|
|
kind="artist"
|
|
title={s.name}
|
|
imageUrl={s.image_url}
|
|
state={cardState(s)}
|
|
attribution={reasonText(s)}
|
|
onRequest={() => onRequest(s)}
|
|
onSnooze={() => onSnooze(s)}
|
|
onUnsnooze={() => onUnsnooze(s.mbid, s.name)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!--
|
|
The snoozed list is not a nicety: a parked suggestion is by definition
|
|
absent from the deck above, so without this there is no way back. Only
|
|
rendered when non-empty, so the surface stays quiet for the common case.
|
|
-->
|
|
{#if snoozes.length > 0}
|
|
<section class="mt-8 border-t border-border pt-6" aria-labelledby="snoozed-heading">
|
|
<h3 id="snoozed-heading" class="font-display text-lg font-medium text-text-primary">
|
|
Not right now
|
|
</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
These come back on their own. Nothing here counts against your taste profile.
|
|
</p>
|
|
<ul class="mt-3 divide-y divide-border">
|
|
{#each snoozes as snoozed (snoozed.mbid)}
|
|
<li class="flex items-center justify-between gap-4 py-2">
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm text-text-primary">{snoozed.name}</div>
|
|
<div class="text-xs text-text-secondary">
|
|
Back {returnsIn(snoozed.snoozed_until)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label={`Bring ${snoozed.name} back now`}
|
|
class="shrink-0 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(snoozed.mbid, snoozed.name)}
|
|
>
|
|
Bring back
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
</div>
|