refactor: the admin and user Requests pages draw a request with one RequestRow, and the kind chip is one KindPill there and on Hidden tracks (#5651)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// A small accent-tinted chip naming what something is: a request's kind,
|
||||
// a hidden track's reason. Beside StatusPill, which says where it stands.
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<span class="kind-pill">{@render children()}</span>
|
||||
|
||||
<style>
|
||||
.kind-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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" module>
|
||||
import type { MusicRequest } from '#lib/api/types.js';
|
||||
|
||||
// A request's name for aria-labels: '—' means nothing to a screen reader,
|
||||
// so a missing title falls back to a phrase that still says what it is.
|
||||
export function requestName(r: MusicRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
|
||||
return r.track_title ?? `this track by ${r.artist_name}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Disc3, Album, Music2 } from 'lucide-svelte';
|
||||
import StatusPill from '#lib/components/StatusPill.svelte';
|
||||
import KindPill from '#lib/components/KindPill.svelte';
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
|
||||
// One request in a list, as the requester's page and the admin queue both
|
||||
// show it: what was asked for, where it stands, and how much of it has
|
||||
// arrived. Each page supplies who or when (meta), its own extra lines
|
||||
// (details) and its own controls (actions).
|
||||
let {
|
||||
request: r,
|
||||
meta,
|
||||
testid,
|
||||
details,
|
||||
actions
|
||||
}: {
|
||||
request: MusicRequest;
|
||||
meta: string;
|
||||
testid: string;
|
||||
details?: Snippet;
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const Icon = $derived(r.kind === 'artist' ? Disc3 : r.kind === 'album' ? Album : Music2);
|
||||
const title = $derived(
|
||||
r.kind === 'artist' ? r.artist_name : r.kind === 'album' ? (r.album_title ?? '—') : (r.track_title ?? '—')
|
||||
);
|
||||
</script>
|
||||
|
||||
<li class="flex items-start gap-4 p-3" data-testid={testid} data-status={r.status}>
|
||||
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover" aria-hidden="true">
|
||||
<Icon size={24} strokeWidth={1} class="text-text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<KindPill>{r.kind[0].toUpperCase() + r.kind.slice(1)}</KindPill>
|
||||
<StatusPill status={r.status} />
|
||||
</div>
|
||||
<div class="truncate text-base font-medium text-text-primary">{title}</div>
|
||||
<div class="truncate text-sm text-text-secondary">{meta}</div>
|
||||
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
||||
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
||||
{#if r.kind === 'artist'}
|
||||
{plural(r.imported_album_count, 'album', 'albums')}
|
||||
· {plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||
{:else if r.kind === 'album'}
|
||||
{plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||
{:else}
|
||||
Track ingested
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{@render details?.()}
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import type { MusicRequest } from '#lib/api/types.js';
|
||||
import RequestRow, { requestName } from './RequestRow.svelte';
|
||||
|
||||
function req(over: Partial<MusicRequest> = {}): MusicRequest {
|
||||
return {
|
||||
id: 'r1',
|
||||
user_id: 'u1',
|
||||
status: 'approved',
|
||||
kind: 'album',
|
||||
artist_mbid: 'art-mbid',
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Geogaddi',
|
||||
track_title: null,
|
||||
requested_at: '2026-04-28T12:00:00Z',
|
||||
updated_at: '2026-04-28T12:00:00Z',
|
||||
imported_album_count: 0,
|
||||
imported_track_count: 0,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
const text = (el: HTMLElement) => (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// The row both Requests pages draw (#5651): the admin queue and the
|
||||
// requester's own list read the same request the same way.
|
||||
describe('RequestRow', () => {
|
||||
test('says what was asked for, where it stands, and who or when', () => {
|
||||
render(RequestRow, { request: req(), meta: 'by Boards of Canada · 4/28/2026', testid: 'request-row' });
|
||||
const row = screen.getByTestId('request-row');
|
||||
expect(row.dataset.status).toBe('approved');
|
||||
expect(text(row)).toMatch(/^Album Approved · downloading Geogaddi by Boards of Canada · 4\/28\/2026$/);
|
||||
});
|
||||
|
||||
test('says how much has arrived, in the words for each kind', () => {
|
||||
const { unmount } = render(RequestRow, {
|
||||
request: req({ kind: 'artist', imported_album_count: 3, imported_track_count: 41 }),
|
||||
meta: '',
|
||||
testid: 'r'
|
||||
});
|
||||
expect(text(screen.getByTestId('ingest-progress'))).toBe('3 albums · 41 tracks ingested');
|
||||
unmount();
|
||||
|
||||
render(RequestRow, { request: req({ kind: 'track', track_title: 'Roygbiv', imported_track_count: 1 }), meta: '', testid: 'r' });
|
||||
expect(text(screen.getByTestId('ingest-progress'))).toBe('Track ingested');
|
||||
});
|
||||
|
||||
test('nothing arrived yet says nothing about it', () => {
|
||||
render(RequestRow, { request: req(), meta: '', testid: 'r' });
|
||||
expect(screen.queryByTestId('ingest-progress')).toBeNull();
|
||||
});
|
||||
|
||||
test('a request with no title still has a name a screen reader can say', () => {
|
||||
render(RequestRow, { request: req({ album_title: null }), meta: '', testid: 'r' });
|
||||
expect(text(screen.getByTestId('r'))).toMatch(/—/);
|
||||
expect(requestName(req({ album_title: null }))).toBe('this album by Boards of Canada');
|
||||
expect(requestName(req({ kind: 'artist' }))).toBe('Boards of Canada');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
|
||||
import { Check, X, SlidersHorizontal } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
@@ -9,9 +8,9 @@
|
||||
import { createReleaseProfilesQuery } from '#lib/api/wanted.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import StatusPill from '#lib/components/StatusPill.svelte';
|
||||
import RequestRow, { requestName } from '#lib/components/RequestRow.svelte';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import type { MusicRequest, RequestKind, RequestStatus } from '#lib/api/types.js';
|
||||
import type { MusicRequest, RequestStatus } from '#lib/api/types.js';
|
||||
|
||||
// Approval queue. Tabs filter by status; rows expose Approve/Reject, and an
|
||||
// artist request can be approved on a chosen release-type profile. Approving
|
||||
@@ -50,24 +49,6 @@
|
||||
let rejectOpen = $state<string | null>(null);
|
||||
let rejectNotes = $state<string>('');
|
||||
|
||||
function fallbackIcon(kind: RequestKind) {
|
||||
if (kind === 'artist') return Disc3;
|
||||
if (kind === 'album') return Album;
|
||||
return Music2;
|
||||
}
|
||||
|
||||
function rowTitle(r: MusicRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? '—';
|
||||
return r.track_title ?? '—';
|
||||
}
|
||||
|
||||
function rowAccessibleName(r: MusicRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
|
||||
return r.track_title ?? `this track by ${r.artist_name}`;
|
||||
}
|
||||
|
||||
// The id is a UUID — first 8 chars is plenty to disambiguate without a real
|
||||
// username, and matches what the polish pass will replace with the real
|
||||
// username surface.
|
||||
@@ -174,38 +155,8 @@
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each rows as r (r.id)}
|
||||
{@const Icon = fallbackIcon(r.kind)}
|
||||
<li class="flex items-start gap-4 p-3" data-testid="admin-request-row" data-status={r.status}>
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon size={24} strokeWidth={1} class="text-text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="kind-pill">{r.kind}</span>
|
||||
<StatusPill status={r.status} />
|
||||
</div>
|
||||
<div class="truncate text-base font-medium text-text-primary">
|
||||
{rowTitle(r)}
|
||||
</div>
|
||||
<div class="truncate text-sm text-text-secondary">
|
||||
{rowMeta(r)}
|
||||
</div>
|
||||
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
||||
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
||||
{#if r.kind === 'artist'}
|
||||
{plural(r.imported_album_count, 'album', 'albums')}
|
||||
· {plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||
{:else if r.kind === 'album'}
|
||||
{plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||
{:else}
|
||||
Track ingested
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<RequestRow request={r} meta={rowMeta(r)} testid="admin-request-row">
|
||||
{#snippet details()}
|
||||
{#if r.kind === 'track' && r.album_title}
|
||||
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
|
||||
Approving wants the album <em class="font-medium text-text-primary">{r.album_title}</em>.
|
||||
@@ -214,39 +165,38 @@
|
||||
{#if r.notes}
|
||||
<div class="text-sm text-text-secondary">{r.notes}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if r.status === 'pending'}
|
||||
{@const primary: RowAction = {
|
||||
icon: Check,
|
||||
label: 'Approve',
|
||||
ariaLabel: `Approve ${rowAccessibleName(r)}`,
|
||||
onclick: () => onApprove(r)
|
||||
}}
|
||||
{@const secondary: RowAction[] = [
|
||||
...(r.kind === 'artist'
|
||||
? [
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
label: 'Approve on a profile',
|
||||
ariaLabel: `Approve ${rowAccessibleName(r)} on a profile`,
|
||||
onclick: () => openProfile(r)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: X,
|
||||
label: 'Reject',
|
||||
ariaLabel: `Reject ${rowAccessibleName(r)}`,
|
||||
onclick: () => openReject(r),
|
||||
danger: true
|
||||
}
|
||||
]}
|
||||
<div class="shrink-0">
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if r.status === 'pending'}
|
||||
{@const primary: RowAction = {
|
||||
icon: Check,
|
||||
label: 'Approve',
|
||||
ariaLabel: `Approve ${requestName(r)}`,
|
||||
onclick: () => onApprove(r)
|
||||
}}
|
||||
{@const secondary: RowAction[] = [
|
||||
...(r.kind === 'artist'
|
||||
? [
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
label: 'Approve on a profile',
|
||||
ariaLabel: `Approve ${requestName(r)} on a profile`,
|
||||
onclick: () => openProfile(r)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: X,
|
||||
label: 'Reject',
|
||||
ariaLabel: `Reject ${requestName(r)}`,
|
||||
onclick: () => openReject(r),
|
||||
danger: true
|
||||
}
|
||||
]}
|
||||
<RowActionsMenu {primary} {secondary} />
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</RequestRow>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -328,17 +278,3 @@
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.kind-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);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import KindPill from '#lib/components/KindPill.svelte';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Music2, RotateCcw } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -64,8 +65,8 @@
|
||||
<!-- Body -->
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="kind-pill">Track</span>
|
||||
<span class="kind-pill">{REASON_LABELS[row.reason]}</span>
|
||||
<KindPill>Track</KindPill>
|
||||
<KindPill>{REASON_LABELS[row.reason]}</KindPill>
|
||||
</div>
|
||||
<div class="text-sm text-text-primary">{row.track_title}</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
@@ -90,16 +91,3 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kind-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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Disc3, Album, Music2, X } from 'lucide-svelte';
|
||||
import { Music2, X } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createMyRequestsQuery, cancelRequest } from '#lib/api/requests.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import StatusPill from '#lib/components/StatusPill.svelte';
|
||||
import RequestRow, { requestName } from '#lib/components/RequestRow.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import DiscoverTabs from '#lib/components/DiscoverTabs.svelte';
|
||||
import type { MusicRequest, RequestKind } from '#lib/api/types.js';
|
||||
import type { MusicRequest } from '#lib/api/types.js';
|
||||
|
||||
const queryStore = createMyRequestsQuery();
|
||||
const query = $derived($queryStore);
|
||||
@@ -26,26 +25,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackIcon(kind: RequestKind) {
|
||||
if (kind === 'artist') return Disc3;
|
||||
if (kind === 'album') return Album;
|
||||
return Music2;
|
||||
}
|
||||
|
||||
function rowTitle(r: MusicRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? '—';
|
||||
return r.track_title ?? '—';
|
||||
}
|
||||
|
||||
// Used in aria-labels — '—' is meaningless to screen readers, so when a
|
||||
// title is missing fall back to a generic-but-intelligible phrase.
|
||||
function rowAccessibleName(r: MusicRequest): string {
|
||||
if (r.kind === 'artist') return r.artist_name;
|
||||
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
|
||||
return r.track_title ?? `this track by ${r.artist_name}`;
|
||||
}
|
||||
|
||||
// Keep the meta line short and readable. We always lead with the artist,
|
||||
// then a relative-ish date — locale formatting is good enough for v1
|
||||
// and keeps tests deterministic without pinning Intl.RelativeTimeFormat.
|
||||
@@ -86,51 +65,20 @@
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each requests as r (r.id)}
|
||||
{@const Icon = fallbackIcon(r.kind)}
|
||||
{@const href = listenHref(r)}
|
||||
<li class="flex items-center gap-4 p-3" data-testid="request-row" data-status={r.status}>
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon size={24} strokeWidth={1} class="text-text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="kind-pill">{r.kind}</span>
|
||||
<StatusPill status={r.status} />
|
||||
</div>
|
||||
<div class="truncate text-base font-medium text-text-primary">
|
||||
{rowTitle(r)}
|
||||
</div>
|
||||
<div class="truncate text-sm text-text-secondary">
|
||||
{rowMeta(r)}
|
||||
</div>
|
||||
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
||||
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
||||
{#if r.kind === 'artist'}
|
||||
{plural(r.imported_album_count, 'album', 'albums')}
|
||||
· {plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||
{:else if r.kind === 'album'}
|
||||
{plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||
{:else}
|
||||
Track ingested
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<RequestRow request={r} meta={rowMeta(r)} testid="request-row">
|
||||
{#snippet details()}
|
||||
{#if r.status === 'rejected' && r.notes}
|
||||
<div class="text-sm text-text-secondary" data-testid="rejection-notes">
|
||||
{r.notes}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if r.status === 'pending'}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Cancel request for ${rowAccessibleName(r)}`}
|
||||
aria-label={`Cancel request for ${requestName(r)}`}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover"
|
||||
onclick={() => onCancel(r.id)}
|
||||
>
|
||||
@@ -139,29 +87,15 @@
|
||||
{:else if r.status === 'completed' && href}
|
||||
<a
|
||||
{href}
|
||||
aria-label={`Listen to ${rowAccessibleName(r)}`}
|
||||
aria-label={`Listen to ${requestName(r)}`}
|
||||
class="inline-flex items-center gap-1 text-sm text-accent-fg hover:underline"
|
||||
>
|
||||
<Music2 size={16} strokeWidth={1} /> Listen
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/snippet}
|
||||
</RequestRow>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kind-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);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user