refactor: every count in the web app is said by one plural(), with the viewer's thousands separator; a source guard names any hand-written copy (M505 #5642)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
|
import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
|
||||||
|
|
||||||
// The queues that wait on an admin (#5605), in the order the Overview lists
|
// The queues that wait on an admin (#5605), in the order the Overview lists
|
||||||
@@ -30,5 +31,5 @@ export function countOf(summary: AdminSummary | undefined, key: QueueKey): numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function queueLabel(def: QueueDef, n: number): string {
|
export function queueLabel(def: QueueDef, n: number): string {
|
||||||
return `${n} ${n === 1 ? def.one : def.many}`;
|
return plural(n, def.one, def.many);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
export type ApiError = {
|
export type ApiError = {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -18,7 +19,7 @@ export function rateLimitMessage(err: unknown): string | null {
|
|||||||
const secs = apiErr.retryAfter;
|
const secs = apiErr.retryAfter;
|
||||||
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
|
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
|
||||||
const mins = Math.ceil(secs / 60);
|
const mins = Math.ceil(secs / 60);
|
||||||
return `Too many attempts. Try again in ${mins} minute${mins === 1 ? '' : 's'}.`;
|
return `Too many attempts. Try again in ${plural(mins, 'minute', 'minutes')}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte';
|
import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const n = await revokeOtherSessions();
|
const n = await revokeOtherSessions();
|
||||||
pushToast(n === 1 ? 'Signed out 1 other device.' : `Signed out ${n} other devices.`);
|
pushToast(`Signed out ${plural(n, 'other device', 'other devices')}.`);
|
||||||
confirmingLogoutOthers = false;
|
confirmingLogoutOthers = false;
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
if (mins < 1) return 'just now';
|
if (mins < 1) return 'just now';
|
||||||
if (mins < 60) return `${mins} min ago`;
|
if (mins < 60) return `${mins} min ago`;
|
||||||
const hours = Math.round(mins / 60);
|
const hours = Math.round(mins / 60);
|
||||||
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
if (hours < 24) return `${plural(hours, 'hour', 'hours')} ago`;
|
||||||
const days = Math.round(hours / 24);
|
const days = Math.round(hours / 24);
|
||||||
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
|
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
|
||||||
return new Date(iso).toLocaleDateString();
|
return new Date(iso).toLocaleDateString();
|
||||||
@@ -192,7 +193,7 @@
|
|||||||
{#if confirmingLogoutOthers}
|
{#if confirmingLogoutOthers}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm text-text-secondary">
|
<span class="text-sm text-text-secondary">
|
||||||
Sign out {others === 1 ? '1 other device' : `${others} other devices`}? You'll stay
|
Sign out {plural(others, 'other device', 'other devices')}? You'll stay
|
||||||
signed in here.
|
signed in here.
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
getNetworkSettings,
|
getNetworkSettings,
|
||||||
@@ -84,9 +85,8 @@
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
|
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
|
||||||
<p class="px-4 py-3 text-sm text-text-primary">
|
<p class="px-4 py-3 text-sm text-text-primary">
|
||||||
This request arrived with {suggested}
|
This request arrived with {plural(suggested, 'forwarded address', 'forwarded addresses')}, so
|
||||||
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, so {suggested}
|
{plural(suggested, 'proxy is', 'proxies are')} likely.
|
||||||
{suggested === 1 ? 'proxy is' : 'proxies are'} likely.
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Verification, not decoration: "the address Minstrel sees for you"
|
<!-- Verification, not decoration: "the address Minstrel sees for you"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
|
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
|
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
|
||||||
@@ -227,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full min-w-0">
|
<div class="w-full min-w-0">
|
||||||
<div class="truncate text-xs text-text-muted">
|
<div class="truncate text-xs text-text-muted">
|
||||||
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
|
{plural(playlist.track_count, 'track', 'tracks')}
|
||||||
{#if !isOwn}
|
{#if !isOwn}
|
||||||
· by {playlist.owner_username}
|
· by {playlist.owner_username}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
||||||
import { player, clearQueue } from '#lib/player/store.svelte.js';
|
import { player, clearQueue } from '#lib/player/store.svelte.js';
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Queue</h2>
|
<h2 class="text-lg font-semibold">Queue</h2>
|
||||||
<p class="text-xs text-text-secondary">
|
<p class="text-xs text-text-secondary">
|
||||||
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
|
{plural(player.queue.length, 'track', 'tracks')}
|
||||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
getReacquisitionSettings,
|
getReacquisitionSettings,
|
||||||
@@ -96,8 +97,7 @@
|
|||||||
{#if saved && saved.unnameable_albums > 0}
|
{#if saved && saved.unnameable_albums > 0}
|
||||||
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
|
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
|
||||||
<p class="px-4 py-3 text-sm text-text-primary">
|
<p class="px-4 py-3 text-sm text-text-primary">
|
||||||
{saved.unnameable_albums}
|
{plural(saved.unnameable_albums, 'album has', 'albums have')} missing files but no MusicBrainz ID,
|
||||||
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no MusicBrainz ID,
|
|
||||||
so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted. Tag and re-scan to fix.
|
so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted. Tag and re-scan to fix.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
createReleaseProfilesQuery,
|
createReleaseProfilesQuery,
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-text-secondary">
|
<div class="text-xs text-text-secondary">
|
||||||
{describe(p)} · {p.follow_count === 1 ? '1 artist' : `${p.follow_count} artists`}
|
{describe(p)} · {plural(p.follow_count, 'artist', 'artists')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
createCandidatesQuery,
|
createCandidatesQuery,
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
function meta(c: Candidate): string {
|
function meta(c: Candidate): string {
|
||||||
// An indexer that states no size sends 0.
|
// An indexer that states no size sends 0.
|
||||||
const parts = [c.format || 'format not stated', c.size_bytes > 0 ? formatBytes(c.size_bytes) : ''];
|
const parts = [c.format || 'format not stated', c.size_bytes > 0 ? formatBytes(c.size_bytes) : ''];
|
||||||
parts.push(c.seeders === 1 ? '1 seeder' : `${c.seeders} seeders`);
|
parts.push(plural(c.seeders, 'seeder', 'seeders'));
|
||||||
if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer);
|
if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer);
|
||||||
return parts.filter(Boolean).join(' · ');
|
return parts.filter(Boolean).join(' · ');
|
||||||
}
|
}
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-text-muted">
|
<p class="text-xs text-text-muted">
|
||||||
{items.length === 1 ? '1 release' : `${items.length} releases`} found {relativeTime(items[0].searched_at)}, best first.
|
{plural(items.length, 'release', 'releases')} found {relativeTime(items[0].searched_at)}, best first.
|
||||||
</p>
|
</p>
|
||||||
<ul class="divide-y divide-border rounded-md border border-border">
|
<ul class="divide-y divide-border rounded-md border border-border">
|
||||||
{#each items as c (c.key)}
|
{#each items as c (c.key)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { Download as DownloadIcon } from 'lucide-svelte';
|
import { Download as DownloadIcon } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
|
|
||||||
function filed(d: Download): string | null {
|
function filed(d: Download): string | null {
|
||||||
if (d.import_state !== 'imported' && d.import_state !== 'held') return null;
|
if (d.import_state !== 'imported' && d.import_state !== 'held') return null;
|
||||||
let said = d.imported_files === 1 ? '1 track filed' : `${d.imported_files} tracks filed`;
|
let said = plural(d.imported_files, 'track filed', 'tracks filed');
|
||||||
if (d.skipped) said += `, ${d.skipped} already held`;
|
if (d.skipped) said += `, ${d.skipped} already held`;
|
||||||
if (d.destination) said += ` into ${d.destination}`;
|
if (d.destination) said += ` into ${d.destination}`;
|
||||||
return said;
|
return said;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { Disc3 } from 'lucide-svelte';
|
import { Disc3 } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-text-secondary">
|
<div class="text-xs text-text-secondary">
|
||||||
{f.open_wants === 1 ? '1 album wanted' : `${f.open_wants} albums wanted`} ·
|
{plural(f.open_wants, 'album wanted', 'albums wanted')} ·
|
||||||
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
|
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { CircleCheck } from 'lucide-svelte';
|
import { CircleCheck } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
|
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
|
||||||
@@ -57,7 +58,6 @@
|
|||||||
return `${u.title} is back to be matched.`;
|
return `${u.title} is back to be matched.`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tracks = (n: number) => (n === 1 ? '1 track' : `${n} tracks`);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if unmatched.isPending}
|
{#if unmatched.isPending}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<a href="/albums/{u.album_id}" class="block truncate text-sm font-medium text-text-primary hover:underline"
|
<a href="/albums/{u.album_id}" class="block truncate text-sm font-medium text-text-primary hover:underline"
|
||||||
>{u.title}</a
|
>{u.title}</a
|
||||||
>
|
>
|
||||||
<div class="truncate text-xs text-text-secondary">{u.artist} · {tracks(u.tracks)}</div>
|
<div class="truncate text-xs text-text-secondary">{u.artist} · {plural(u.tracks, 'track', 'tracks')}</div>
|
||||||
<p class="text-xs text-text-muted">{u.detail}</p>
|
<p class="text-xs text-text-muted">{u.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-2">
|
<div class="flex shrink-0 gap-2">
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<div class="min-w-0 flex-1 space-y-0.5">
|
<div class="min-w-0 flex-1 space-y-0.5">
|
||||||
<a href="/albums/{u.album_id}" class="block truncate text-sm text-text-primary hover:underline">{u.title}</a>
|
<a href="/albums/{u.album_id}" class="block truncate text-sm text-text-primary hover:underline">{u.title}</a>
|
||||||
<div class="truncate text-xs text-text-secondary">
|
<div class="truncate text-xs text-text-secondary">
|
||||||
{u.artist} · {tracks(u.tracks)} · left off {relativeTime(u.decided_at)}
|
{u.artist} · {plural(u.tracks, 'track', 'tracks')} · left off {relativeTime(u.decided_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte';
|
import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||||
@@ -98,8 +99,7 @@
|
|||||||
function arrived(w: Want): string | null {
|
function arrived(w: Want): string | null {
|
||||||
const g = w.grab;
|
const g = w.grab;
|
||||||
if (!g || g.import_state !== 'imported' || !g.imported_at) return null;
|
if (!g || g.import_state !== 'imported' || !g.imported_at) return null;
|
||||||
const n = g.imported_files === 1 ? '1 track' : `${g.imported_files} tracks`;
|
return `Imported ${plural(g.imported_files, 'track', 'tracks')} ${relativeTime(g.imported_at)} from ${g.name}`;
|
||||||
return `Imported ${n} ${relativeTime(g.imported_at)} from ${g.name}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A want the schedule still searches for: open, or held but wanted for a
|
// A want the schedule still searches for: open, or held but wanted for a
|
||||||
@@ -119,7 +119,6 @@
|
|||||||
return 'Its files went missing';
|
return 'Its files went missing';
|
||||||
}
|
}
|
||||||
|
|
||||||
const albums = (n: number) => (n === 1 ? '1 album' : `${n} albums`);
|
|
||||||
|
|
||||||
function kind(w: Want): string {
|
function kind(w: Want): string {
|
||||||
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
|
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
|
||||||
@@ -141,7 +140,7 @@
|
|||||||
<a href="/collection" class="text-xs text-text-secondary hover:text-text-primary">← Catalogue</a>
|
<a href="/collection" class="text-xs text-text-secondary hover:text-text-primary">← Catalogue</a>
|
||||||
<h2 class="truncate text-xl font-medium text-text-primary">{head.artist_name}</h2>
|
<h2 class="truncate text-xl font-medium text-text-primary">{head.artist_name}</h2>
|
||||||
<p class="text-sm text-text-secondary" data-testid="catalogue-artist-counts">
|
<p class="text-sm text-text-secondary" data-testid="catalogue-artist-counts">
|
||||||
{head.held} of {albums(head.albums)} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing
|
{head.held} of {plural(head.albums, 'album', 'albums')} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing
|
||||||
? ` · ${head.missing} with files missing`
|
? ` · ${head.missing} with files missing`
|
||||||
: ''}{head.downloading ? ` · ${head.downloading} downloading` : ''}
|
: ''}{head.downloading ? ` · ${head.downloading} downloading` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -200,7 +199,7 @@
|
|||||||
{a.name}
|
{a.name}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 text-xs text-text-muted">{albums(a.wants.length)}</span>
|
<span class="shrink-0 text-xs text-text-muted">{plural(a.wants.length, 'album', 'albums')}</span>
|
||||||
</h2>{/if}
|
</h2>{/if}
|
||||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||||
{#each a.wants as w (w.id)}
|
{#each a.wants as w (w.id)}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { plural } from './plural.js';
|
||||||
|
|
||||||
|
describe('plural', () => {
|
||||||
|
test('says a count with the noun that agrees with it', () => {
|
||||||
|
expect(plural(1, 'track', 'tracks')).toBe('1 track');
|
||||||
|
expect(plural(0, 'track', 'tracks')).toBe('0 tracks');
|
||||||
|
expect(plural(2, 'album has', 'albums have')).toBe('2 albums have');
|
||||||
|
expect(plural(26_171, 'track', 'tracks')).toBe('26,171 tracks');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// One way to say a count (M505). Before it there were ~55 hand-written
|
||||||
|
// copies and two helpers that disagreed on the thousands separator. A
|
||||||
|
// hand-written copy has a tell this guard looks for:
|
||||||
|
// - a branch that spells the one: `n === 1 ? '1 track' : ...`;
|
||||||
|
// - the number beside a choice of noun: `{n} {n === 1 ? ...}` in markup,
|
||||||
|
// or `${n} ${n === 1 ? ...}` in a template string;
|
||||||
|
// - the suffix trick: `minute${n === 1 ? '' : 's'}`.
|
||||||
|
// A choice of words with no count in it ("it" / "they", "yesterday") is
|
||||||
|
// not a count label and stays a ternary.
|
||||||
|
const SRC = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
|
||||||
|
const HAND_ROLLED = [
|
||||||
|
/=== 1 \? ['"`]1 /,
|
||||||
|
/\{([\w.]+)\}\s*\{\1 === 1 \?/,
|
||||||
|
/\$\{([\w.]+)\} \$\{\1 === 1 \?/,
|
||||||
|
/=== 1 \? '' : 's'/
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Comments describe the forbidden shape; strip them before asserting its absence. */
|
||||||
|
const stripComments = (s: string) => s.replace(/<!--[\s\S]*?-->/g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||||
|
|
||||||
|
function sources(dir: string): string[] {
|
||||||
|
return readdirSync(dir).flatMap((name) => {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) return sources(path);
|
||||||
|
return /\.(svelte|ts)$/.test(name) && !/\.test\.ts$/.test(name) ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('count labels', () => {
|
||||||
|
test('every count in the web app is said by plural()', () => {
|
||||||
|
const offenders = sources(SRC).flatMap((path) => {
|
||||||
|
const text = stripComments(readFileSync(path, 'utf8'));
|
||||||
|
return HAND_ROLLED.some((re) => re.test(text)) ? [relative(SRC, path)] : [];
|
||||||
|
});
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// A count with its noun: "1 track", "26,171 tracks". The noun may carry the
|
||||||
|
// words that agree with it ("album has" / "albums have"). The number is in the
|
||||||
|
// viewer's locale, as the app's other counts are. The web app's one way to say
|
||||||
|
// a count (M505); a source guard in plural.test.ts names any hand-written copy.
|
||||||
|
export function plural(n: number, one: string, many: string): string {
|
||||||
|
return `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
||||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
if (dl.needs_you > 0) {
|
if (dl.needs_you > 0) {
|
||||||
out.push({
|
out.push({
|
||||||
key: 'downloads',
|
key: 'downloads',
|
||||||
text: dl.needs_you === 1 ? '1 download needs you' : `${dl.needs_you} downloads need you`,
|
text: plural(dl.needs_you, 'download needs you', 'downloads need you'),
|
||||||
since: dl.needs_you_since,
|
since: dl.needs_you_since,
|
||||||
href: '/collection/activity'
|
href: '/collection/activity'
|
||||||
});
|
});
|
||||||
@@ -66,7 +67,6 @@
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
const plural = (n: number, one: string, many: string) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
|
|
||||||
|
|
||||||
// Worded as the wanted list words it; before the client reports, it is
|
// Worded as the wanted list words it; before the client reports, it is
|
||||||
// simply downloading, as the Downloads list says.
|
// simply downloading, as the Downloads list says.
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
{#if problems === 0}
|
{#if problems === 0}
|
||||||
Nothing needs you.
|
Nothing needs you.
|
||||||
{:else}
|
{:else}
|
||||||
{problems === 1 ? '1 thing needs you.' : `${problems} things need you.`}
|
{plural(problems, 'thing needs you.', 'things need you.')}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{#if problems > 0}
|
{#if problems > 0}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
|
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -196,10 +197,10 @@
|
|||||||
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
||||||
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
||||||
{#if r.kind === 'artist'}
|
{#if r.kind === 'artist'}
|
||||||
{r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'}
|
{plural(r.imported_album_count, 'album', 'albums')}
|
||||||
· {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested
|
· {plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||||
{:else if r.kind === 'album'}
|
{:else if r.kind === 'album'}
|
||||||
{r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested
|
{plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||||
{:else}
|
{:else}
|
||||||
Track ingested
|
Track ingested
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { qk } from '#lib/api/queries.js';
|
import { qk } from '#lib/api/queries.js';
|
||||||
@@ -227,8 +228,8 @@
|
|||||||
{#if usersQuery.data}
|
{#if usersQuery.data}
|
||||||
{@const admins = users.filter((u) => u.is_admin).length}
|
{@const admins = users.filter((u) => u.is_admin).length}
|
||||||
<p class="text-sm text-text-secondary" data-testid="users-summary">
|
<p class="text-sm text-text-secondary" data-testid="users-summary">
|
||||||
{users.length === 1 ? '1 account' : `${users.length} accounts`}, {admins === 1 ? '1 admin' : `${admins} admins`}{invitesQuery.data
|
{plural(users.length, 'account', 'accounts')}, {plural(admins, 'admin', 'admins')}{invitesQuery.data
|
||||||
? ` · ${invites.length === 1 ? '1 invite open' : `${invites.length} invites open`}`
|
? ` · ${plural(invites.length, 'invite open', 'invites open')}`
|
||||||
: ''}.
|
: ''}.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import PageColumn from '#lib/components/PageColumn.svelte';
|
import PageColumn from '#lib/components/PageColumn.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createAlbumQuery } from '#lib/api/queries.js';
|
import { createAlbumQuery } from '#lib/api/queries.js';
|
||||||
@@ -140,7 +141,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
{plural(album.track_count, 'track', 'tracks')}
|
||||||
· {formatDuration(album.duration_sec)}
|
· {formatDuration(album.duration_sec)}
|
||||||
</p>
|
</p>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import PlayActions from '#lib/components/PlayActions.svelte';
|
import PlayActions from '#lib/components/PlayActions.svelte';
|
||||||
import { shuffled } from '#lib/utils/shuffle.js';
|
import { shuffled } from '#lib/utils/shuffle.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
<LikeButton entityType="artist" entityId={detail.id} size="lg" />
|
<LikeButton entityType="artist" entityId={detail.id} size="lg" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
|
{plural(detail.album_count, 'album', 'albums')}
|
||||||
</p>
|
</p>
|
||||||
<!-- Genre quick-jumps (#367). No year link here: an artist spans many,
|
<!-- Genre quick-jumps (#367). No year link here: an artist spans many,
|
||||||
so a single year would be a lie about the discography. -->
|
so a single year would be a lie about the discography. -->
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||||
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
||||||
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||||
@@ -18,13 +19,13 @@
|
|||||||
const verdict = $derived.by(() => {
|
const verdict = $derived.by(() => {
|
||||||
if (!counts) return '';
|
if (!counts) return '';
|
||||||
if (counts.attention) {
|
if (counts.attention) {
|
||||||
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`;
|
return `${plural(counts.attention, 'download needs', 'downloads need')} you.`;
|
||||||
}
|
}
|
||||||
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
|
const parts = [plural(counts.wanted, 'album wanted', 'albums wanted')];
|
||||||
if (counts.activity) parts.push(`${counts.activity} downloading`);
|
if (counts.activity) parts.push(`${counts.activity} downloading`);
|
||||||
// Library albums no MusicBrainz album matched wait on a person too (#5579).
|
// Library albums no MusicBrainz album matched wait on a person too (#5579).
|
||||||
if (counts.unmatched) {
|
if (counts.unmatched) {
|
||||||
const n = counts.unmatched === 1 ? '1 album in your library needs' : `${counts.unmatched} albums in your library need`;
|
const n = plural(counts.unmatched, 'album in your library needs', 'albums in your library need');
|
||||||
return parts.join(', ') + `. ${n} matching.`;
|
return parts.join(', ') + `. ${n} matching.`;
|
||||||
}
|
}
|
||||||
return parts.join(', ') + '. Nothing needs you.';
|
return parts.join(', ') + '. Nothing needs you.';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { Copy, Info, TriangleAlert } from 'lucide-svelte';
|
import { Copy, Info, TriangleAlert } from 'lucide-svelte';
|
||||||
@@ -103,10 +104,6 @@
|
|||||||
return keepChoice[group.id] ?? group.survivor_track_id;
|
return keepChoice[group.id] ?? group.survivor_track_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileCountLabel(n: number): string {
|
|
||||||
return n === 1 ? '1 file' : `${n} files`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMerge(group: AdminDuplicateGroup) {
|
async function onMerge(group: AdminDuplicateGroup) {
|
||||||
// First click arms; the second, on the button that now names how many files
|
// First click arms; the second, on the button that now names how many files
|
||||||
// go, does it. A merge deletes files, and nothing brings them back.
|
// go, does it. A merge deletes files, and nothing brings them back.
|
||||||
@@ -118,7 +115,7 @@
|
|||||||
merging = group.id;
|
merging = group.id;
|
||||||
try {
|
try {
|
||||||
const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) });
|
const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) });
|
||||||
pushToast(`Merged. Removed ${fileCountLabel(result.removed_paths.length)}.`);
|
pushToast(`Merged. Removed ${plural(result.removed_paths.length, 'file', 'files')}.`);
|
||||||
query.refetch();
|
query.refetch();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
pushToast(errMessage(e), 'error');
|
pushToast(errMessage(e), 'error');
|
||||||
@@ -136,10 +133,6 @@
|
|||||||
return `Same recording · ${match}% match`;
|
return `Same recording · ${match}% match`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countLabel(n: number, one: string, many: string): string {
|
|
||||||
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identical audio under different titles means at least one file's tags are
|
// Identical audio under different titles means at least one file's tags are
|
||||||
// wrong, and the recording the other title names may not be in the library at
|
// wrong, and the recording the other title names may not be in the library at
|
||||||
// all. #3885: "WWW" was a second copy of the instrumental, and the vocal was
|
// all. #3885: "WWW" was a second copy of the instrumental, and the vocal was
|
||||||
@@ -164,7 +157,7 @@
|
|||||||
// What the copy carries is the fact that decides which to keep.
|
// What the copy carries is the fact that decides which to keep.
|
||||||
function historyLabel(m: AdminDuplicateMember): string {
|
function historyLabel(m: AdminDuplicateMember): string {
|
||||||
if (m.like_count === 0 && m.play_count === 0) return 'no likes or plays';
|
if (m.like_count === 0 && m.play_count === 0) return 'no likes or plays';
|
||||||
return `${countLabel(m.like_count, 'like', 'likes')} · ${countLabel(m.play_count, 'play', 'plays')}`;
|
return `${plural(m.like_count, 'like', 'likes')} · ${plural(m.play_count, 'play', 'plays')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function position(m: AdminDuplicateMember): string {
|
function position(m: AdminDuplicateMember): string {
|
||||||
@@ -253,7 +246,7 @@
|
|||||||
{#if item.details.survivor_reason}
|
{#if item.details.survivor_reason}
|
||||||
<span class="text-text-secondary">({item.details.survivor_reason})</span>
|
<span class="text-text-secondary">({item.details.survivor_reason})</span>
|
||||||
{/if}
|
{/if}
|
||||||
and removed {fileCountLabel(removed.length)}.
|
and removed {plural(removed.length, 'file', 'files')}.
|
||||||
</p>
|
</p>
|
||||||
{#each removed as r (r.track_id)}
|
{#each removed as r (r.track_id)}
|
||||||
<p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p>
|
<p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p>
|
||||||
@@ -346,7 +339,7 @@
|
|||||||
{#if merging === group.id}
|
{#if merging === group.id}
|
||||||
Merging…
|
Merging…
|
||||||
{:else if confirmingMerge === group.id}
|
{:else if confirmingMerge === group.id}
|
||||||
Remove {fileCountLabel(group.members.length - 1)} and merge
|
Remove {plural(group.members.length - 1, 'file', 'files')} and merge
|
||||||
{:else}
|
{:else}
|
||||||
Merge…
|
Merge…
|
||||||
{/if}
|
{/if}
|
||||||
@@ -382,7 +375,7 @@
|
|||||||
<!-- What the second click will do, said plainly before it is done. -->
|
<!-- What the second click will do, said plainly before it is done. -->
|
||||||
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm">
|
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm">
|
||||||
<p class="text-text-primary">
|
<p class="text-text-primary">
|
||||||
The copy marked Keep stays. The other {fileCountLabel(group.members.length - 1)} will be
|
The copy marked Keep stays. The other {plural(group.members.length - 1, 'file', 'files')} will be
|
||||||
deleted from disk, and their likes, plays and playlist entries move to the copy kept.
|
deleted from disk, and their likes, plays and playlist entries move to the copy kept.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { ChevronDown, FolderX, Music2, Search, X } from 'lucide-svelte';
|
import { ChevronDown, FolderX, Music2, Search, X } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -40,10 +41,6 @@
|
|||||||
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
|
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
|
||||||
const hasMore = $derived(offset + shown < total);
|
const hasMore = $derived(offset + shown < total);
|
||||||
|
|
||||||
function trackCountLabel(n: number): string {
|
|
||||||
return n === 1 ? '1 track' : `${n} tracks`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// What happens to these is set under Acquisition (#5607); the line above
|
// What happens to these is set under Acquisition (#5607); the line above
|
||||||
// the list says what it is now, since this is where the question comes up.
|
// the list says what it is now, since this is where the question comes up.
|
||||||
let policy = $state<ReacquisitionSettings | null>(null);
|
let policy = $state<ReacquisitionSettings | null>(null);
|
||||||
@@ -158,7 +155,7 @@
|
|||||||
{group.directory}
|
{group.directory}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="shrink-0 text-xs text-text-secondary">
|
<span class="shrink-0 text-xs text-text-secondary">
|
||||||
{trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)}
|
{plural(group.tracks.length, 'track', 'tracks')} · gone {relativeTime(group.missing_since)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { Music2, CheckCircle2, Trash2, Play, Copy, ChevronRight } from 'lucide-svelte';
|
import { Music2, CheckCircle2, Trash2, Play, Copy, ChevronRight } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -144,7 +145,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { FileCheck, Music2 } from 'lucide-svelte';
|
import { FileCheck, Music2 } from 'lucide-svelte';
|
||||||
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
|
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
|
||||||
@@ -64,10 +65,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackCountLabel(n: number): string {
|
|
||||||
return n === 1 ? '1 track' : `${n} tracks`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function basename(path: string): string {
|
function basename(path: string): string {
|
||||||
return path.slice(path.lastIndexOf('/') + 1);
|
return path.slice(path.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
@@ -134,7 +131,7 @@
|
|||||||
{group.directory}
|
{group.directory}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="shrink-0 text-xs text-text-secondary">
|
<span class="shrink-0 text-xs text-text-secondary">
|
||||||
{trackCountLabel(group.tracks.length)}
|
{plural(group.tracks.length, 'track', 'tracks')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { createAlbumsAlphaInfiniteQuery } from '#lib/api/albums.js';
|
import { createAlbumsAlphaInfiniteQuery } from '#lib/api/albums.js';
|
||||||
import AlbumCard from '#lib/components/AlbumCard.svelte';
|
import AlbumCard from '#lib/components/AlbumCard.svelte';
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
<h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1>
|
||||||
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
|
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{total} {total === 1 ? 'album' : 'albums'}
|
{plural(total, 'album', 'albums')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { createArtistsQuery } from '#lib/api/queries.js';
|
import { createArtistsQuery } from '#lib/api/queries.js';
|
||||||
import ArtistCard from '#lib/components/ArtistCard.svelte';
|
import ArtistCard from '#lib/components/ArtistCard.svelte';
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
<h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1>
|
||||||
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
|
{#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{total} {total === 1 ? 'artist' : 'artists'}
|
{plural(total, 'artist', 'artists')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { ChevronLeft } from 'lucide-svelte';
|
import { ChevronLeft } from 'lucide-svelte';
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
|
||||||
{#if !loading || albums.length > 0}
|
{#if !loading || albums.length > 0}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{total} {total === 1 ? 'album' : 'albums'}
|
{plural(total, 'album', 'albums')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +184,7 @@
|
|||||||
{#if filter.trim()}
|
{#if filter.trim()}
|
||||||
{visibleGenres.length} of {genres.length} genres
|
{visibleGenres.length} of {genres.length} genres
|
||||||
{:else}
|
{:else}
|
||||||
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
|
{plural(genres.length, 'genre', 'genres')}, straight from your file tags
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { ChevronLeft } from 'lucide-svelte';
|
import { ChevronLeft } from 'lucide-svelte';
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
|
||||||
{#if !loading || albums.length > 0}
|
{#if !loading || albums.length > 0}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{total} {total === 1 ? 'album' : 'albums'}
|
{plural(total, 'album', 'albums')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +162,7 @@
|
|||||||
<h1 class="font-display text-2xl font-medium text-text-primary">Years</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">Years</h1>
|
||||||
{#if !index.isPending && !index.isError}
|
{#if !index.isPending && !index.isError}
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
{years.length} {years.length === 1 ? 'year' : 'years'} with dated releases
|
{plural(years.length, 'year', 'years')} with dated releases
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import PageColumn from '#lib/components/PageColumn.svelte';
|
import PageColumn from '#lib/components/PageColumn.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -272,7 +273,7 @@
|
|||||||
<p class="text-sm text-text-muted">{pl.description}</p>
|
<p class="text-sm text-text-muted">{pl.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="text-xs text-text-muted">
|
<p class="text-xs text-text-muted">
|
||||||
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
{plural(pl.track_count, 'track', 'tracks')}
|
||||||
{#if pl.is_public}· public{:else}· private{/if}
|
{#if pl.is_public}· public{:else}· private{/if}
|
||||||
{#if !isOwner}· by {pl.owner_username}{/if}
|
{#if !isOwner}· by {pl.owner_username}{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { Disc3, Album, Music2, X } from 'lucide-svelte';
|
import { Disc3, Album, Music2, X } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -109,10 +110,10 @@
|
|||||||
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
|
||||||
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
|
||||||
{#if r.kind === 'artist'}
|
{#if r.kind === 'artist'}
|
||||||
{r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'}
|
{plural(r.imported_album_count, 'album', 'albums')}
|
||||||
· {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested
|
· {plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||||
{:else if r.kind === 'album'}
|
{:else if r.kind === 'album'}
|
||||||
{r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested
|
{plural(r.imported_track_count, 'track', 'tracks')} ingested
|
||||||
{:else}
|
{:else}
|
||||||
Track ingested
|
Track ingested
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createSearchAlbumsInfiniteQuery } from '#lib/api/queries.js';
|
import { createSearchAlbumsInfiniteQuery } from '#lib/api/queries.js';
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
|
<h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
|
||||||
{#if q && !query?.isPending && !(query?.isError && albums.length === 0)}
|
{#if q && !query?.isPending && !(query?.isError && albums.length === 0)}
|
||||||
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p>
|
<p class="text-sm text-text-secondary">{plural(total, 'album', 'albums')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createSearchArtistsInfiniteQuery } from '#lib/api/queries.js';
|
import { createSearchArtistsInfiniteQuery } from '#lib/api/queries.js';
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
|
<h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
|
||||||
{#if q && !query?.isPending && !(query?.isError && artists.length === 0)}
|
{#if q && !query?.isPending && !(query?.isError && artists.length === 0)}
|
||||||
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p>
|
<p class="text-sm text-text-secondary">{plural(total, 'artist', 'artists')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
import PageColumn from '#lib/components/PageColumn.svelte';
|
import PageColumn from '#lib/components/PageColumn.svelte';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
|
<h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
|
||||||
{#if q && !query?.isPending && !(query?.isError && tracks.length === 0)}
|
{#if q && !query?.isPending && !(query?.isError && tracks.length === 0)}
|
||||||
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
|
<p class="text-sm text-text-secondary">{plural(total, 'track', 'tracks')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user