feat(metadata): add original-language preferences (#526)

* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
This commit is contained in:
Quick
2026-07-31 15:23:30 -04:00
committed by GitHub
parent d70b291bb8
commit 73488d1bfa
46 changed files with 1572 additions and 437 deletions
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ORIGINAL_METADATA_LANGUAGE } from "@/lib/metadataLanguagePreferences";
import { MetadataLanguageSetting } from "./MetadataLanguageSetting";
const languageOptions = [
{ value: "en", label: "English" },
{ value: "ja", label: "Japanese" },
{ value: "no", label: "Norwegian" },
];
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
window.HTMLElement.prototype.hasPointerCapture ??= () => false;
window.HTMLElement.prototype.scrollIntoView ??= () => {};
describe("MetadataLanguageSetting", () => {
afterEach(cleanup);
it("shows a source-language exception and removes only that rule", () => {
const onOverridesChange = vi.fn();
render(
<MetadataLanguageSetting
fallback="en"
overrides={{ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" }}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={onOverridesChange}
/>,
);
expect(screen.getByText("Norwegian")).toBeTruthy();
expect(screen.getByText("Japanese")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Remove Norwegian exception" }));
expect(onOverridesChange).toHaveBeenCalledWith({ ja: "en" });
});
it("keeps the add action disabled until an original language is chosen", () => {
render(
<MetadataLanguageSetting
fallback={ORIGINAL_METADATA_LANGUAGE}
overrides={{}}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Add exception" }).hasAttribute("disabled")).toBe(
true,
);
expect(screen.getByLabelText("Original language for new exception")).toBeTruthy();
});
it("shows a new exception immediately while its save is still pending", async () => {
const user = userEvent.setup();
const onOverridesChange = vi.fn(() => new Promise<void>(() => {}));
render(
<MetadataLanguageSetting
fallback="en"
overrides={{}}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={onOverridesChange}
/>,
);
await user.click(screen.getByLabelText("Original language for new exception"));
await user.click(screen.getByRole("option", { name: "Norwegian" }));
await user.click(screen.getByRole("button", { name: "Add exception" }));
expect(screen.getByLabelText("Metadata language for Norwegian")).toHaveTextContent(
"Original language",
);
expect(onOverridesChange).toHaveBeenCalledWith({ no: ORIGINAL_METADATA_LANGUAGE });
});
it("rolls back an optimistic exception when its save fails", async () => {
const user = userEvent.setup();
let rejectSave: (reason: Error) => void = () => {};
const onOverridesChange = vi.fn(
() =>
new Promise<void>((_, reject) => {
rejectSave = reject;
}),
);
render(
<MetadataLanguageSetting
fallback="en"
overrides={{}}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={onOverridesChange}
/>,
);
await user.click(screen.getByLabelText("Original language for new exception"));
await user.click(screen.getByRole("option", { name: "Norwegian" }));
await user.click(screen.getByRole("button", { name: "Add exception" }));
expect(screen.getByLabelText("Metadata language for Norwegian")).toBeInTheDocument();
rejectSave(new Error("save failed"));
await waitFor(() =>
expect(screen.queryByLabelText("Metadata language for Norwegian")).not.toBeInTheDocument(),
);
});
it("keeps a custom target selectable when it is outside the advisory catalog", () => {
render(
<MetadataLanguageSetting
fallback="en"
overrides={{ no: "pt-BR" }}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={vi.fn()}
/>,
);
expect(screen.getByLabelText("Metadata language for Norwegian").textContent).toContain(
"Portuguese",
);
});
});
@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { SettingRow } from "@/components/settings/SettingRow";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { withCurrentLanguageOption, type SettingOption } from "@/lib/languageOptions";
import { getLanguageName } from "@/lib/languageNames";
import {
ORIGINAL_METADATA_LANGUAGE,
withMetadataLanguageOverride,
withoutMetadataLanguageOverride,
type MetadataLanguageOverrides,
} from "@/lib/metadataLanguagePreferences";
interface MetadataLanguageSettingProps {
fallback: string | null;
overrides: MetadataLanguageOverrides;
languageOptions: readonly SettingOption[];
disabled?: boolean;
onFallbackChange: (language: string | null) => void;
onOverridesChange: (overrides: MetadataLanguageOverrides) => void | Promise<void>;
}
const NO_PREFERENCE = "__library_default";
function overridesEqual(
left: MetadataLanguageOverrides,
right: MetadataLanguageOverrides,
): boolean {
const leftEntries = Object.entries(left);
return (
leftEntries.length === Object.keys(right).length &&
leftEntries.every(([source, target]) => right[source] === target)
);
}
export function MetadataLanguageSetting({
fallback,
overrides,
languageOptions,
disabled = false,
onFallbackChange,
onOverridesChange,
}: MetadataLanguageSettingProps) {
const [newSource, setNewSource] = useState("");
const [optimisticOverrides, setOptimisticOverrides] = useState<{
base: MetadataLanguageOverrides;
value: MetadataLanguageOverrides;
} | null>(null);
const currentOverrides =
optimisticOverrides !== null && overridesEqual(overrides, optimisticOverrides.base)
? optimisticOverrides.value
: overrides;
const entries = useMemo(
() =>
Object.entries(currentOverrides).sort(([left], [right]) =>
getLanguageName(left).localeCompare(getLanguageName(right)),
),
[currentOverrides],
);
const namedOptions = useMemo(
() => languageOptions.filter((language) => language.value !== ORIGINAL_METADATA_LANGUAGE),
[languageOptions],
);
const availableSources = namedOptions.filter((language) => !(language.value in currentOverrides));
const optionsForTarget = (target: string) => {
if (target === ORIGINAL_METADATA_LANGUAGE) return namedOptions;
return withCurrentLanguageOption(namedOptions, target);
};
const changeOverrides = async (next: MetadataLanguageOverrides) => {
const pending = { base: overrides, value: next };
setOptimisticOverrides(pending);
try {
await onOverridesChange(next);
} catch {
setOptimisticOverrides((current) => (current === pending ? null : current));
}
};
const addException = () => {
if (!newSource) return;
void changeOverrides(
withMetadataLanguageOverride(currentOverrides, newSource, ORIGINAL_METADATA_LANGUAGE),
);
setNewSource("");
};
return (
<SettingRow
label="Metadata language"
description="Choose the fallback for titles and descriptions, then add exceptions based on each item's original language. Missing descriptions can be translated automatically when AI translation is enabled."
control={(id) => (
<div className="w-full min-w-0 space-y-3 md:w-[430px]">
<Select
value={fallback ?? NO_PREFERENCE}
onValueChange={(value) => onFallbackChange(value === NO_PREFERENCE ? null : value)}
>
<SelectTrigger id={id} className="w-full" disabled={disabled}>
<SelectValue placeholder="Library default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PREFERENCE}>Library default</SelectItem>
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
{namedOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
{entries.length > 0 ? (
<div className="border-border/60 divide-border/60 divide-y border-y">
{entries.map(([source, target]) => (
<div
key={source}
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)_auto]"
>
<span className="truncate text-sm">{getLanguageName(source)}</span>
<Select
value={target}
onValueChange={(value) =>
void changeOverrides(
withMetadataLanguageOverride(currentOverrides, source, value),
)
}
>
<SelectTrigger
aria-label={`Metadata language for ${getLanguageName(source)}`}
className="col-span-2 col-start-1 row-start-2 h-9 w-full sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-8"
disabled={disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
{optionsForTarget(target).map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="col-start-2 row-start-1 size-9 sm:col-start-3 sm:size-8"
disabled={disabled}
aria-label={`Remove ${getLanguageName(source)} exception`}
onClick={() =>
void changeOverrides(
withoutMetadataLanguageOverride(currentOverrides, source),
)
}
>
<Trash2 className="size-3.5" aria-hidden="true" />
</Button>
</div>
))}
</div>
) : null}
{availableSources.length > 0 ? (
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={newSource} onValueChange={setNewSource}>
<SelectTrigger
aria-label="Original language for new exception"
className="h-10 w-full min-w-0 flex-1 sm:h-9 sm:w-auto"
disabled={disabled}
>
<SelectValue placeholder="Choose original language" />
</SelectTrigger>
<SelectContent>
{availableSources.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 w-full sm:h-8 sm:w-auto"
disabled={disabled || !newSource}
onClick={addException}
>
<Plus className="mr-1.5 size-3.5" aria-hidden="true" />
Add exception
</Button>
</div>
) : null}
</div>
)}
/>
);
}