# Conflicts: # frontend/package-lock.json # frontend/package.jsonpull/2182/head
commit
080710e7e1
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,34 @@
|
||||
import { useLanguages } from "@/apis/hooks";
|
||||
import { Selector, SelectorProps } from "@/components/inputs";
|
||||
import { useSelectorOptions } from "@/utilities";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
interface LanguageSelectorProps
|
||||
extends Omit<SelectorProps<Language.Server>, "options" | "getkey"> {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const LanguageSelector: FunctionComponent<LanguageSelectorProps> = ({
|
||||
enabled = false,
|
||||
...selector
|
||||
}) => {
|
||||
const { data } = useLanguages();
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (enabled) {
|
||||
return data?.filter((value) => value.enabled);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}, [data, enabled]);
|
||||
|
||||
const options = useSelectorOptions(
|
||||
filteredData ?? [],
|
||||
(value) => value.name,
|
||||
(value) => value.code3
|
||||
);
|
||||
|
||||
return <Selector {...options} searchable {...selector}></Selector>;
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
@ -1,4 +1,4 @@
|
||||
export * from "./inputs";
|
||||
export { default as Search } from "./Search";
|
||||
export * from "./inputs";
|
||||
export * from "./tables";
|
||||
export { default as Toolbox } from "./toolbox";
|
||||
|
@ -1,20 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useRoutes } from "react-router-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Router } from "./Router";
|
||||
import { AllProviders } from "./providers";
|
||||
import { useRouteItems } from "./Router";
|
||||
|
||||
const RouteApp = () => {
|
||||
const items = useRouteItems();
|
||||
const container = document.getElementById("root");
|
||||
|
||||
return useRoutes(items);
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
<StrictMode>
|
||||
<AllProviders>
|
||||
<RouteApp />
|
||||
</AllProviders>
|
||||
</StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
if (container === null) {
|
||||
Error("Cannot initialize app, root not found");
|
||||
} else {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<AllProviders>
|
||||
<Router />
|
||||
</AllProviders>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
export * from "./hooks";
|
||||
export { default as ModalsProvider } from "./ModalsProvider";
|
||||
export { default as withModal } from "./WithModal";
|
||||
export * from "./hooks";
|
||||
|
@ -0,0 +1,196 @@
|
||||
import {
|
||||
decodeEqualData,
|
||||
encodeEqualData,
|
||||
LanguageEqualData,
|
||||
LanguageEqualImmediateData,
|
||||
} from "@/pages/Settings/Languages/equals";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Equals Parser", () => {
|
||||
it("should parse from string correctly", () => {
|
||||
interface TestData {
|
||||
text: string;
|
||||
expected: LanguageEqualImmediateData;
|
||||
}
|
||||
|
||||
function testParsedResult(
|
||||
text: string,
|
||||
expected: LanguageEqualImmediateData
|
||||
) {
|
||||
const result = decodeEqualData(text);
|
||||
|
||||
if (result === undefined) {
|
||||
expect(false, `Cannot parse '${text}' as language equal data`);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
result,
|
||||
`${text} does not match with the expected equal data`
|
||||
).toStrictEqual(expected);
|
||||
}
|
||||
|
||||
const testValues: TestData[] = [
|
||||
{
|
||||
text: "spa-MX:spa",
|
||||
expected: {
|
||||
source: {
|
||||
content: "spa-MX",
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
target: {
|
||||
content: "spa",
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "zho@hi:zht",
|
||||
expected: {
|
||||
source: {
|
||||
content: "zho",
|
||||
hi: true,
|
||||
forced: false,
|
||||
},
|
||||
target: {
|
||||
content: "zht",
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "es-MX@forced:es-MX",
|
||||
expected: {
|
||||
source: {
|
||||
content: "es-MX",
|
||||
hi: false,
|
||||
forced: true,
|
||||
},
|
||||
target: {
|
||||
content: "es-MX",
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "en:en@hi",
|
||||
expected: {
|
||||
source: {
|
||||
content: "en",
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
target: {
|
||||
content: "en",
|
||||
hi: true,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
testValues.forEach((data) => {
|
||||
testParsedResult(data.text, data.expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should encode to string correctly", () => {
|
||||
interface TestData {
|
||||
source: LanguageEqualData;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
const testValues: TestData[] = [
|
||||
{
|
||||
source: {
|
||||
source: {
|
||||
content: {
|
||||
name: "Abkhazian",
|
||||
code2: "ab",
|
||||
code3: "abk",
|
||||
enabled: false,
|
||||
},
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
target: {
|
||||
content: {
|
||||
name: "Aragonese",
|
||||
code2: "an",
|
||||
code3: "arg",
|
||||
enabled: false,
|
||||
},
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
expected: "abk:arg",
|
||||
},
|
||||
{
|
||||
source: {
|
||||
source: {
|
||||
content: {
|
||||
name: "Abkhazian",
|
||||
code2: "ab",
|
||||
code3: "abk",
|
||||
enabled: false,
|
||||
},
|
||||
hi: true,
|
||||
forced: false,
|
||||
},
|
||||
target: {
|
||||
content: {
|
||||
name: "Aragonese",
|
||||
code2: "an",
|
||||
code3: "arg",
|
||||
enabled: false,
|
||||
},
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
expected: "abk@hi:arg",
|
||||
},
|
||||
{
|
||||
source: {
|
||||
source: {
|
||||
content: {
|
||||
name: "Abkhazian",
|
||||
code2: "ab",
|
||||
code3: "abk",
|
||||
enabled: false,
|
||||
},
|
||||
hi: false,
|
||||
forced: true,
|
||||
},
|
||||
target: {
|
||||
content: {
|
||||
name: "Aragonese",
|
||||
code2: "an",
|
||||
code3: "arg",
|
||||
enabled: false,
|
||||
},
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
expected: "abk@forced:arg",
|
||||
},
|
||||
];
|
||||
|
||||
function testEncodeResult({ source, expected }: TestData) {
|
||||
const encoded = encodeEqualData(source);
|
||||
|
||||
expect(
|
||||
encoded,
|
||||
`Encoded result '${encoded}' is not matched to '${expected}'`
|
||||
).toEqual(expected);
|
||||
}
|
||||
|
||||
testValues.forEach(testEncodeResult);
|
||||
});
|
||||
});
|
@ -0,0 +1,365 @@
|
||||
import { useLanguages } from "@/apis/hooks";
|
||||
import { Action, SimpleTable } from "@/components";
|
||||
import LanguageSelector from "@/components/bazarr/LanguageSelector";
|
||||
import { languageEqualsKey } from "@/pages/Settings/keys";
|
||||
import { useFormActions } from "@/pages/Settings/utilities/FormValues";
|
||||
import { useSettingValue } from "@/pages/Settings/utilities/hooks";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { faEquals, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button, Checkbox } from "@mantine/core";
|
||||
import { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
|
||||
interface GenericEqualTarget<T> {
|
||||
content: T;
|
||||
hi: boolean;
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
interface LanguageEqualGenericData<T> {
|
||||
source: GenericEqualTarget<T>;
|
||||
target: GenericEqualTarget<T>;
|
||||
}
|
||||
|
||||
export type LanguageEqualImmediateData =
|
||||
LanguageEqualGenericData<Language.CodeType>;
|
||||
|
||||
export type LanguageEqualData = LanguageEqualGenericData<Language.Server>;
|
||||
|
||||
function decodeEqualTarget(
|
||||
text: string
|
||||
): GenericEqualTarget<Language.CodeType> | undefined {
|
||||
const [code, decoration] = text.split("@");
|
||||
|
||||
if (code.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const forced = decoration === "forced";
|
||||
const hi = decoration === "hi";
|
||||
|
||||
return {
|
||||
content: code,
|
||||
forced,
|
||||
hi,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeEqualData(
|
||||
text: string
|
||||
): LanguageEqualImmediateData | undefined {
|
||||
const [first, second] = text.split(":");
|
||||
|
||||
const source = decodeEqualTarget(first);
|
||||
const target = decodeEqualTarget(second);
|
||||
|
||||
if (source === undefined || target === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeEqualTarget(data: GenericEqualTarget<Language.Server>): string {
|
||||
let text = data.content.code3;
|
||||
if (data.hi) {
|
||||
text += "@hi";
|
||||
} else if (data.forced) {
|
||||
text += "@forced";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function encodeEqualData(data: LanguageEqualData): string {
|
||||
const source = encodeEqualTarget(data.source);
|
||||
const target = encodeEqualTarget(data.target);
|
||||
|
||||
return `${source}:${target}`;
|
||||
}
|
||||
|
||||
export function useLatestLanguageEquals(): LanguageEqualData[] {
|
||||
const { data } = useLanguages();
|
||||
|
||||
const latest = useSettingValue<string[]>(languageEqualsKey);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
latest
|
||||
?.map(decodeEqualData)
|
||||
.map((parsed) => {
|
||||
if (parsed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = data?.find(
|
||||
(value) => value.code3 === parsed.source.content
|
||||
);
|
||||
const target = data?.find(
|
||||
(value) => value.code3 === parsed.target.content
|
||||
);
|
||||
|
||||
if (source === undefined || target === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
source: { ...parsed.source, content: source },
|
||||
target: { ...parsed.target, content: target },
|
||||
};
|
||||
})
|
||||
.filter((v): v is LanguageEqualData => v !== undefined) ?? [],
|
||||
[data, latest]
|
||||
);
|
||||
}
|
||||
|
||||
interface EqualsTableProps {}
|
||||
|
||||
const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
|
||||
const { data: languages } = useLanguages();
|
||||
const canAdd = languages !== undefined;
|
||||
|
||||
const equals = useLatestLanguageEquals();
|
||||
|
||||
const { setValue } = useFormActions();
|
||||
|
||||
const setEquals = useCallback(
|
||||
(values: LanguageEqualData[]) => {
|
||||
const encodedValues = values.map(encodeEqualData);
|
||||
|
||||
LOG("info", "updating language equals data", values);
|
||||
setValue(encodedValues, languageEqualsKey);
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const add = useCallback(() => {
|
||||
if (languages === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = languages.find((value) => value.enabled);
|
||||
|
||||
if (enabled === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue: LanguageEqualData[] = [
|
||||
...equals,
|
||||
{
|
||||
source: {
|
||||
content: enabled,
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
target: {
|
||||
content: enabled,
|
||||
hi: false,
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
setEquals(newValue);
|
||||
}, [equals, languages, setEquals]);
|
||||
|
||||
const update = useCallback(
|
||||
(index: number, value: LanguageEqualData) => {
|
||||
if (index < 0 || index >= equals.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue: LanguageEqualData[] = [...equals];
|
||||
|
||||
newValue[index] = { ...value };
|
||||
setEquals(newValue);
|
||||
},
|
||||
[equals, setEquals]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= equals.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue: LanguageEqualData[] = [...equals];
|
||||
|
||||
newValue.splice(index, 1);
|
||||
|
||||
setEquals(newValue);
|
||||
},
|
||||
[equals, setEquals]
|
||||
);
|
||||
|
||||
const columns = useMemo<Column<LanguageEqualData>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Source",
|
||||
id: "source-lang",
|
||||
accessor: "source",
|
||||
Cell: ({ value: { content }, row }) => {
|
||||
return (
|
||||
<LanguageSelector
|
||||
enabled
|
||||
value={content}
|
||||
onChange={(result) => {
|
||||
if (result !== null) {
|
||||
update(row.index, {
|
||||
...row.original,
|
||||
source: { ...row.original.source, content: result },
|
||||
});
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "source-hi",
|
||||
accessor: "source",
|
||||
Cell: ({ value: { hi }, row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
label="HI"
|
||||
checked={hi}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
update(row.index, {
|
||||
...row.original,
|
||||
source: {
|
||||
...row.original.source,
|
||||
hi: checked,
|
||||
forced: checked ? false : row.original.source.forced,
|
||||
},
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "source-forced",
|
||||
accessor: "source",
|
||||
Cell: ({ value: { forced }, row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
label="Forced"
|
||||
checked={forced}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
update(row.index, {
|
||||
...row.original,
|
||||
source: {
|
||||
...row.original.source,
|
||||
forced: checked,
|
||||
hi: checked ? false : row.original.source.hi,
|
||||
},
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "equal-icon",
|
||||
Cell: () => {
|
||||
return <FontAwesomeIcon icon={faEquals} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Target",
|
||||
id: "target-lang",
|
||||
accessor: "target",
|
||||
Cell: ({ value: { content }, row }) => {
|
||||
return (
|
||||
<LanguageSelector
|
||||
enabled
|
||||
value={content}
|
||||
onChange={(result) => {
|
||||
if (result !== null) {
|
||||
update(row.index, {
|
||||
...row.original,
|
||||
target: { ...row.original.target, content: result },
|
||||
});
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target-hi",
|
||||
accessor: "target",
|
||||
Cell: ({ value: { hi }, row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
label="HI"
|
||||
checked={hi}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
update(row.index, {
|
||||
...row.original,
|
||||
target: {
|
||||
...row.original.target,
|
||||
hi: checked,
|
||||
forced: checked ? false : row.original.target.forced,
|
||||
},
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target-forced",
|
||||
accessor: "target",
|
||||
Cell: ({ value: { forced }, row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
label="Forced"
|
||||
checked={forced}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
update(row.index, {
|
||||
...row.original,
|
||||
target: {
|
||||
...row.original.target,
|
||||
forced: checked,
|
||||
hi: checked ? false : row.original.target.hi,
|
||||
},
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessor: "target",
|
||||
Cell: ({ row }) => {
|
||||
return (
|
||||
<Action
|
||||
label="Remove"
|
||||
icon={faTrash}
|
||||
color="red"
|
||||
onClick={() => remove(row.index)}
|
||||
></Action>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[remove, update]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleTable data={equals} columns={columns}></SimpleTable>
|
||||
<Button fullWidth disabled={!canAdd} color="light" onClick={add}>
|
||||
{canAdd ? "Add Equal" : "No Enabled Languages"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EqualsTable;
|
@ -1,41 +1,9 @@
|
||||
// A workaround of built-in hooks in React-Router v6
|
||||
// https://gist.github.com/rmorse/426ffcc579922a82749934826fa9f743
|
||||
|
||||
import type { Blocker, History, Transition } from "history";
|
||||
import { useContext, useEffect } from "react";
|
||||
// eslint-disable-next-line camelcase
|
||||
import { UNSAFE_NavigationContext } from "react-router-dom";
|
||||
|
||||
export function useBlocker(blocker: Blocker, when = true) {
|
||||
const navigator = useContext(UNSAFE_NavigationContext).navigator as History;
|
||||
|
||||
useEffect(() => {
|
||||
if (!when) return;
|
||||
|
||||
const unblock = navigator.block((tx: Transition) => {
|
||||
const autoUnblockingTx = {
|
||||
...tx,
|
||||
retry() {
|
||||
// Automatically unblock the transition so it can play all the way
|
||||
// through before retrying it. TODO: Figure out how to re-enable
|
||||
// this block if the transition is cancelled for some reason.
|
||||
unblock();
|
||||
tx.retry();
|
||||
},
|
||||
};
|
||||
|
||||
blocker(autoUnblockingTx);
|
||||
});
|
||||
|
||||
return unblock;
|
||||
}, [navigator, blocker, when]);
|
||||
}
|
||||
import { unstable_usePrompt as useUnstablePrompt } from "react-router-dom";
|
||||
|
||||
// TODO: Replace with Mantine's confirmation modal
|
||||
export function usePrompt(when: boolean, message: string) {
|
||||
useBlocker((tx) => {
|
||||
if (window.confirm(message)) {
|
||||
tx.retry();
|
||||
}
|
||||
}, when);
|
||||
useUnstablePrompt({ when, message });
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue