refactor(web): consolidate error-copy table to JSON source of truth

Three admin pages had divergent inline error-code → user-copy switch
tables (with wording drift, including a stale "Settings → Integrations"
that should have been Admin). Consolidate into web/src/lib/styles/error-copy.json,
expose via web/src/lib/api/error-copy.ts (ERROR_COPY + copyForCode),
and refactor the three pages to import the helper.

Fixes the three-way drift; the only user-visible behavior change is
the quarantine page now correctly says "Admin → Integrations".

Sets up the JSON for the M7 Flutter client (#356) to consume the same
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 15:01:46 -04:00
parent ea22807a3f
commit c3be0f3e6e
6 changed files with 40 additions and 68 deletions
+8
View File
@@ -0,0 +1,8 @@
import errorCopyJson from '../styles/error-copy.json';
export const ERROR_COPY: Readonly<Record<string, string>> = errorCopyJson;
export function copyForCode(code: string | null | undefined): string {
if (!code) return ERROR_COPY.unknown ?? 'Something went wrong.';
return ERROR_COPY[code] ?? ERROR_COPY.unknown ?? 'Something went wrong.';
}
+16
View File
@@ -0,0 +1,16 @@
{
"unknown": "Something went wrong.",
"unauthenticated": "Your session has ended. Please sign in again.",
"connection_refused": "Couldn't reach the server. Check the URL and try again.",
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled": "Lidarr integration is not enabled.",
"lidarr_auth_failed": "Lidarr authentication failed.",
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.",
"lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing": "This track has no Lidarr album to remove.",
"request_not_pending": "This request is no longer pending.",
"request_not_found": "That request no longer exists.",
"track_not_found": "That track no longer exists."
}
+6 -20
View File
@@ -12,6 +12,7 @@
deleteQuarantineViaLidarr
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
import type {
AdminQuarantineRow,
LidarrRequest,
@@ -60,21 +61,6 @@
toastTimer = setTimeout(() => { toast = null; }, 5000);
}
function errorCopy(code: string): string {
switch (code) {
case 'lidarr_unreachable': return "Lidarr is unreachable. Try again, or check Admin → Integrations.";
case 'lidarr_disabled': return 'Lidarr integration is not enabled.';
case 'lidarr_auth_failed': return 'Lidarr authentication failed.';
case 'lidarr_defaults_incomplete': return 'Lidarr defaults missing — set them in Admin → Integrations.';
case 'lidarr_server_error': return "Lidarr returned an error. Check Lidarr's logs.";
case 'lidarr_rejected': return 'Lidarr rejected the request. Check the server logs for details.';
case 'request_not_pending': return 'This request is no longer pending.';
case 'request_not_found': return 'That request no longer exists.';
case 'track_not_found': return 'That track no longer exists.';
default: return code || 'Action failed.';
}
}
// ---- Two-click destructive confirm for quarantine actions ----
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
// click within 3s fires the action; otherwise the key clears.
@@ -134,7 +120,7 @@
await approveRequest(r.id);
await invalidateRequests();
} catch (e) {
showToast(errorCopy((e as { code?: string }).code ?? ''));
showToast(copyForCode((e as { code?: string }).code));
}
}
async function onReject(r: LidarrRequest) {
@@ -142,7 +128,7 @@
await rejectRequest(r.id);
await invalidateRequests();
} catch (e) {
showToast(errorCopy((e as { code?: string }).code ?? ''));
showToast(copyForCode((e as { code?: string }).code));
}
}
@@ -158,7 +144,7 @@
await resolveQuarantine(row.track_id);
await invalidateQuarantine();
} catch (e) {
showToast(errorCopy((e as { code?: string }).code ?? ''));
showToast(copyForCode((e as { code?: string }).code));
}
}
async function onDeleteFile(row: AdminQuarantineRow) {
@@ -172,7 +158,7 @@
await deleteQuarantineFile(row.track_id);
await invalidateQuarantine();
} catch (e) {
showToast(errorCopy((e as { code?: string }).code ?? ''));
showToast(copyForCode((e as { code?: string }).code));
}
}
async function onDeleteLidarr(row: AdminQuarantineRow) {
@@ -186,7 +172,7 @@
await deleteQuarantineViaLidarr(row.track_id);
await invalidateQuarantine();
} catch (e) {
showToast(errorCopy((e as { code?: string }).code ?? ''));
showToast(copyForCode((e as { code?: string }).code));
}
}
</script>
+6 -22
View File
@@ -8,13 +8,14 @@
deleteQuarantineViaLidarr
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
import { playRadio } from '$lib/player/store.svelte';
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
// Aggregated triage queue. One row per track, with per-row resolution
// actions: Resolve (clears reports), Delete file (Bronze; modal-confirm),
// Delete via Lidarr (Oxblood; typed-confirm "DELETE"). Mirrors
// /admin/requests for shape — toast helper, errorCopy(), invalidateQueries.
// /admin/requests for shape — toast helper, copyForCode(), invalidateQueries.
const client = useQueryClient();
@@ -78,23 +79,6 @@
}, 5000);
}
function errorCopy(code: string): string {
switch (code) {
case 'lidarr_unreachable':
return "Lidarr is unreachable right now. Try again, or check Settings → Integrations.";
case 'lidarr_disabled':
return 'Lidarr integration is not enabled.';
case 'lidarr_auth_failed':
return 'Lidarr authentication failed.';
case 'lidarr_album_lookup_failed':
return "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.";
case 'album_mbid_missing':
return 'This track has no Lidarr album to remove.';
default:
return "Couldn't reach Lidarr.";
}
}
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
}
@@ -105,7 +89,7 @@
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
showToast(copyForCode(code));
}
}
@@ -124,7 +108,7 @@
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
showToast(copyForCode(code));
}
}
@@ -153,9 +137,9 @@
const code = (e as { code?: string }).code ?? 'unknown';
// Inline error keeps the modal open so the operator sees the failure
// alongside the album they were about to remove.
deleteLidarrError = errorCopy(code);
deleteLidarrError = copyForCode(code);
// Also surface as toast so it's visible after dismissing the modal.
showToast(errorCopy(code));
showToast(copyForCode(code));
}
}
@@ -153,7 +153,7 @@ describe('/admin/quarantine', () => {
await waitFor(() =>
expect(
screen.getByText(
'Lidarr is unreachable right now. Try again, or check Settings → Integrations.'
'Lidarr is unreachable right now. Try again, or check Admin → Integrations.'
)
).toBeInTheDocument()
);
+3 -25
View File
@@ -8,6 +8,7 @@
approveRequest,
rejectRequest
} from '$lib/api/admin';
import { copyForCode } from '$lib/api/error-copy';
import StatusPill from '$lib/components/StatusPill.svelte';
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
@@ -67,29 +68,6 @@
}, 5000);
}
function errorCopy(code: string): string {
switch (code) {
case 'lidarr_unreachable':
return "Lidarr is unreachable right now. Try again, or check Admin → Integrations.";
case 'lidarr_disabled':
return 'Lidarr integration is not enabled.';
case 'lidarr_auth_failed':
return 'Lidarr authentication failed.';
case 'lidarr_defaults_incomplete':
return 'Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.';
case 'lidarr_server_error':
return "Lidarr returned an error. Check Lidarr's logs for the cause.";
case 'lidarr_rejected':
return "Lidarr rejected the request. Check the server logs for the field-level reason.";
case 'request_not_pending':
return 'This request is no longer pending.';
case 'request_not_found':
return 'That request no longer exists.';
default:
return code ? code : "Couldn't reach Lidarr.";
}
}
function fallbackIcon(kind: LidarrRequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
@@ -136,7 +114,7 @@
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
showToast(copyForCode(code));
}
}
@@ -177,7 +155,7 @@
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
showToast(copyForCode(code));
}
}