Rewrites the web app's comments to rule 32. Task, milestone, rule and spec ids are gone; regression stories are restated as the trap they guard (public auth routes, the menu's stopPropagation, universal loads under adapter-static, metric deltas with their margin). Flutter and Tauri mentions now name the Android app or a native shell, operator quotes and dates are dropped, and one library's counts no longer appear outside test fixtures. Verified with a TypeScript-scanner token comparison (template literals and regex literals re-scanned): only comment text changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
4.4 KiB
Svelte
119 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import { plural } from '#lib/utils/plural.js';
|
|
import { Disc3 } from 'lucide-svelte';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createFollowsQuery,
|
|
createReleaseProfilesQuery,
|
|
putFollow,
|
|
deleteFollow
|
|
} from '#lib/api/wanted.js';
|
|
import { errMessage } from '#lib/api/errors.js';
|
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
|
import type { Follow } from '#lib/api/types.js';
|
|
|
|
// The Following tab: every artist whose new releases are
|
|
// wanted, each on a release-type profile saying which kinds of release.
|
|
|
|
const client = useQueryClient();
|
|
const followsStore = createFollowsQuery();
|
|
const follows = $derived($followsStore);
|
|
const profilesStore = createReleaseProfilesQuery();
|
|
const profiles = $derived($profilesStore.data?.profiles ?? []);
|
|
|
|
let busy = $state<string | null>(null);
|
|
|
|
async function refresh() {
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: ['adminWants'] }),
|
|
client.invalidateQueries({ queryKey: ['adminFollows'] })
|
|
]);
|
|
}
|
|
|
|
async function onProfile(f: Follow, profileId: string) {
|
|
busy = f.artist_mbid;
|
|
try {
|
|
await putFollow(f.artist_mbid, { artist_name: f.artist_name, profile_id: profileId });
|
|
await refresh();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
busy = null;
|
|
}
|
|
}
|
|
|
|
async function onUnfollow(f: Follow) {
|
|
busy = f.artist_mbid;
|
|
try {
|
|
await deleteFollow(f.artist_mbid);
|
|
pushToast(`No longer following ${f.artist_name}. What was already wanted stays wanted.`);
|
|
await refresh();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
busy = null;
|
|
}
|
|
}
|
|
|
|
function hideBroken(e: Event) {
|
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
}
|
|
</script>
|
|
|
|
{#if follows.isPending}
|
|
<p class="text-text-secondary">Reading who's followed…</p>
|
|
{:else if follows.isError}
|
|
<p class="text-error-fg">Couldn't load the followed artists.</p>
|
|
{:else if (follows.data ?? []).length === 0}
|
|
<p class="text-text-secondary">No artists are followed. Follow one from its artist page.</p>
|
|
{:else}
|
|
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
|
{#each follows.data ?? [] as f (f.artist_mbid)}
|
|
<li class="flex flex-wrap items-center gap-3 p-3" data-testid="follow-row">
|
|
<div class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-hover" aria-hidden="true">
|
|
<Disc3 size={20} strokeWidth={1} class="text-text-muted" />
|
|
{#if f.image_url}
|
|
<img src={f.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
|
|
{/if}
|
|
</div>
|
|
<div class="min-w-0 flex-1 space-y-0.5">
|
|
<div class="truncate text-sm font-medium text-text-primary">
|
|
{#if f.artist_id}
|
|
<a href="/artists/{f.artist_id}" class="hover:underline">{f.artist_name}</a>
|
|
{:else}
|
|
{f.artist_name}
|
|
{/if}
|
|
</div>
|
|
<div class="text-xs text-text-secondary">
|
|
{plural(f.open_wants, 'album wanted', 'albums wanted')} ·
|
|
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
|
|
</div>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-xs text-text-secondary">
|
|
<span class="sr-only">Profile for {f.artist_name}</span>
|
|
<select
|
|
value={f.profile_id}
|
|
disabled={busy === f.artist_mbid}
|
|
onchange={(e) => onProfile(f, (e.currentTarget as HTMLSelectElement).value)}
|
|
class="rounded-md border border-border bg-background px-2 py-1 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
|
|
>
|
|
{#each profiles as p (p.id)}
|
|
<option value={p.id}>{p.name}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onclick={() => onUnfollow(f)}
|
|
disabled={busy === f.artist_mbid}
|
|
aria-label="Unfollow {f.artist_name}"
|
|
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
|
>
|
|
Unfollow
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|