localization for ntfy and mail
This commit is contained in:
@@ -6,6 +6,8 @@ export interface ServerConfig {
|
||||
databasePath: string;
|
||||
paperlessBaseUrl: string | null;
|
||||
paperlessExternalUrl: string | null;
|
||||
appExternalUrl: string | null;
|
||||
appLocale: string;
|
||||
paperlessConfigured: boolean;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
@@ -26,6 +28,8 @@ export interface SettingsResponse {
|
||||
values: {
|
||||
paperlessBaseUrl: string | null;
|
||||
paperlessExternalUrl: string | null;
|
||||
appExternalUrl: string | null;
|
||||
appLocale: string;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
mailServer: string | null;
|
||||
@@ -51,6 +55,8 @@ export interface SettingsResponse {
|
||||
export type UpdateSettingsPayload = Partial<{
|
||||
paperlessBaseUrl: string | null;
|
||||
paperlessExternalUrl: string | null;
|
||||
appExternalUrl: string | null;
|
||||
appLocale: string;
|
||||
paperlessToken: string | null;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"edit": "Bearbeiten",
|
||||
"deleted": "Vertrag gelöscht",
|
||||
"deleteError": "Löschen fehlgeschlagen",
|
||||
"categoryAddTitle": "Kategorie hinzufügen",
|
||||
"categoryAdd": "Neue Kategorie hinzufügen",
|
||||
"categoryAddDescription": "Erstelle eine neue Kategorie, die du sofort auswählen kannst.",
|
||||
"categoryNameLabel": "Name der Kategorie",
|
||||
@@ -86,6 +87,7 @@
|
||||
"categoryNameRequired": "Bitte einen Kategorienamen angeben.",
|
||||
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
|
||||
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
|
||||
"categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
|
||||
"categoryNone": "Keine Kategorie",
|
||||
"categoryLoading": "Kategorien werden geladen…",
|
||||
"categoryLoadError": "Kategorien konnten nicht geladen werden."
|
||||
@@ -177,7 +179,14 @@
|
||||
"icalFeedUrl": "Feed-URL",
|
||||
"paperlessApiUrl": "Paperless API URL",
|
||||
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
|
||||
"appExternalUrl": "Externe URL der App",
|
||||
"paperlessExample": "https://paperless.example.com",
|
||||
"appExternalExample": "https://contracts.example.com",
|
||||
"appExternalUrlHelp": "Diese URL wird in iCal-Feeds und Links verwendet. Leer lassen, um Host des Aufrufs zu nutzen.",
|
||||
"appLocaleLabel": "Sprache für Benachrichtigungen",
|
||||
"appLocaleHelp": "Bestimmt die Sprache für ntfy-Pushs und Mail-Betreffs.",
|
||||
"appLocaleGerman": "Deutsch",
|
||||
"appLocaleEnglish": "Englisch",
|
||||
"paperlessToken": "Paperless Token",
|
||||
"paperlessTokenNew": "Neuen Token hinterlegen",
|
||||
"paperlessTokenPlaceholder": "Token eingeben",
|
||||
@@ -236,6 +245,7 @@
|
||||
"categoryNameRequired": "Bitte einen Namen für die Kategorie eingeben.",
|
||||
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
|
||||
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
|
||||
"categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
|
||||
"categoryDeleted": "Kategorie gelöscht",
|
||||
"categoryDeleteError": "Kategorie konnte nicht gelöscht werden",
|
||||
"categoryEmpty": "Noch keine Kategorien vorhanden.",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"edit": "Edit",
|
||||
"deleted": "Contract deleted",
|
||||
"deleteError": "Failed to delete",
|
||||
"categoryAddTitle": "Add category",
|
||||
"categoryAdd": "Add new category",
|
||||
"categoryAddDescription": "Create a new category you can select immediately.",
|
||||
"categoryNameLabel": "Category name",
|
||||
@@ -86,6 +87,7 @@
|
||||
"categoryNameRequired": "Please provide a category name.",
|
||||
"categoryCreated": "Category \"{{name}}\" saved",
|
||||
"categoryCreateError": "Failed to create category",
|
||||
"categoryExists": "Category \"{{name}}\" already exists",
|
||||
"categoryNone": "No category",
|
||||
"categoryLoading": "Loading categories…",
|
||||
"categoryLoadError": "Could not load categories."
|
||||
@@ -177,7 +179,14 @@
|
||||
"icalFeedUrl": "Feed URL",
|
||||
"paperlessApiUrl": "Paperless API URL",
|
||||
"paperlessExternalUrl": "Paperless external URL (for direct link)",
|
||||
"appExternalUrl": "App external URL",
|
||||
"paperlessExample": "https://paperless.example.com",
|
||||
"appExternalExample": "https://contracts.example.com",
|
||||
"appExternalUrlHelp": "Used for links and iCal feeds. Leave empty to use the request host.",
|
||||
"appLocaleLabel": "Notification language",
|
||||
"appLocaleHelp": "Controls the language used in ntfy pushes and email subjects.",
|
||||
"appLocaleGerman": "German",
|
||||
"appLocaleEnglish": "English",
|
||||
"paperlessToken": "Paperless token",
|
||||
"paperlessTokenNew": "Provide new token",
|
||||
"paperlessTokenPlaceholder": "Enter token",
|
||||
@@ -236,6 +245,7 @@
|
||||
"categoryNameRequired": "Please enter a category name.",
|
||||
"categoryCreated": "Category \"{{name}}\" saved",
|
||||
"categoryCreateError": "Failed to create category",
|
||||
"categoryExists": "Category \"{{name}}\" already exists",
|
||||
"categoryDeleted": "Category deleted",
|
||||
"categoryDeleteError": "Failed to delete category",
|
||||
"categoryEmpty": "No categories defined yet.",
|
||||
|
||||
@@ -17,12 +17,13 @@ import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
|
||||
import { fetchServerConfig, ServerConfig } from "../api/config";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
import { translateCategoryName } from "../utils/categories";
|
||||
|
||||
export default function ContractDetail() {
|
||||
const { contractId } = useParams<{ contractId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const id = Number(contractId);
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const { data: contract, isLoading } = useQuery({
|
||||
queryKey: ["contracts", id],
|
||||
@@ -56,6 +57,9 @@ export default function ContractDetail() {
|
||||
? t("contractForm.fields.autoRenew")
|
||||
: "–";
|
||||
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
|
||||
const categoryValue = contract?.category
|
||||
? translateCategoryName(contract.category, i18n.language) || contract.category
|
||||
: "–";
|
||||
|
||||
if (!Number.isFinite(id)) {
|
||||
return <Typography>{t("contractForm.loadError")}</Typography>;
|
||||
@@ -89,7 +93,7 @@ export default function ContractDetail() {
|
||||
<Detail label={t("contractDetail.notice")} value={terminationValue} />
|
||||
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
|
||||
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
|
||||
<Detail label={t("contractDetail.category")} value={contract.category ?? "–"} />
|
||||
<Detail label={t("contractDetail.category")} value={categoryValue} />
|
||||
<Detail label={t("contractDetail.notes")} value={notesValue} />
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "../api/contracts";
|
||||
import { fetchServerConfig } from "../api/config";
|
||||
import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories";
|
||||
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
|
||||
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
@@ -94,7 +95,7 @@ export default function ContractForm({ mode }: Props) {
|
||||
const id = contractId ? Number(contractId) : null;
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -160,7 +161,8 @@ export default function ContractForm({ mode }: Props) {
|
||||
await refetchCategories();
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setValue("category", category.name, { shouldDirty: true });
|
||||
showMessage(t("contracts.categoryCreated", { name: category.name }), "success");
|
||||
const displayName = translateCategoryName(category.name, i18n.language) || category.name;
|
||||
showMessage(t("contracts.categoryCreated", { name: displayName }), "success");
|
||||
setCategoryDialogOpen(false);
|
||||
setNewCategoryName("");
|
||||
},
|
||||
@@ -171,6 +173,34 @@ export default function ContractForm({ mode }: Props) {
|
||||
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const trimmedDialogCategoryName = newCategoryName.trim();
|
||||
const dialogCategoryExists = useMemo(() => {
|
||||
if (!trimmedDialogCategoryName) {
|
||||
return false;
|
||||
}
|
||||
if (!categories) {
|
||||
return false;
|
||||
}
|
||||
const normalizedInput = trimmedDialogCategoryName.toLowerCase();
|
||||
const canonicalInput = getCanonicalDefaultCategoryName(trimmedDialogCategoryName)?.toLowerCase() ?? null;
|
||||
return categories.some((category) => {
|
||||
const normalizedCategory = category.name.toLowerCase();
|
||||
return normalizedCategory === normalizedInput || (canonicalInput ? normalizedCategory === canonicalInput : false);
|
||||
});
|
||||
}, [categories, trimmedDialogCategoryName]);
|
||||
|
||||
const handleDialogCreateCategory = () => {
|
||||
if (!trimmedDialogCategoryName) {
|
||||
showMessage(t("contracts.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
if (dialogCategoryExists) {
|
||||
const existingDisplayName = translateCategoryName(trimmedDialogCategoryName, i18n.language) || trimmedDialogCategoryName;
|
||||
showMessage(t("contracts.categoryExists", { name: existingDisplayName }), "info");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmedDialogCategoryName);
|
||||
};
|
||||
|
||||
const providerSuggestion = useMemo(
|
||||
() => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null),
|
||||
@@ -342,7 +372,15 @@ export default function ContractForm({ mode }: Props) {
|
||||
render={({ field }) => {
|
||||
const options = categories ?? [];
|
||||
const value = field.value ?? "";
|
||||
const hasCurrentValue = Boolean(value) && options.some((category) => category.name.toLowerCase() === String(value).toLowerCase());
|
||||
const normalizedValue = String(value).toLowerCase();
|
||||
const canonicalValue = value ? getCanonicalDefaultCategoryName(String(value))?.toLowerCase() ?? null : null;
|
||||
const hasCurrentValue = Boolean(value) && options.some((category) => {
|
||||
const normalizedCategory = category.name.toLowerCase();
|
||||
if (normalizedCategory === normalizedValue) {
|
||||
return true;
|
||||
}
|
||||
return canonicalValue ? normalizedCategory === canonicalValue : false;
|
||||
});
|
||||
return (
|
||||
<TextField
|
||||
label={t("contractForm.fields.category")}
|
||||
@@ -362,6 +400,7 @@ export default function ContractForm({ mode }: Props) {
|
||||
field.onChange(nextValue);
|
||||
}}
|
||||
SelectProps={{ displayEmpty: true }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
disabled={loadingCategories}
|
||||
error={Boolean(categoriesError)}
|
||||
helperText={categoriesError ? t("contracts.categoryLoadError") : undefined}
|
||||
@@ -380,13 +419,13 @@ export default function ContractForm({ mode }: Props) {
|
||||
) : (
|
||||
options.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
{translateCategoryName(category.name, i18n.language) || category.name}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
|
||||
<MenuItem value={value as string}>
|
||||
{String(value)}
|
||||
{translateCategoryName(String(value), i18n.language) || String(value)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
|
||||
@@ -559,6 +598,12 @@ export default function ContractForm({ mode }: Props) {
|
||||
label={t("contracts.categoryNameLabel")}
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleDialogCreateCategory();
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -570,15 +615,8 @@ export default function ContractForm({ mode }: Props) {
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("contracts.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
onClick={handleDialogCreateCategory}
|
||||
disabled={createCategoryMutation.isPending || !trimmedDialogCategoryName}
|
||||
variant="contained"
|
||||
>
|
||||
{t("contracts.categoryCreate")}
|
||||
|
||||
@@ -34,12 +34,13 @@ import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { Contract } from "../types";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
import { translateCategoryName } from "../utils/categories";
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
||||
|
||||
const {
|
||||
@@ -72,7 +73,7 @@ export default function ContractsList() {
|
||||
return normalizedContracts.filter((contract) => {
|
||||
const searchMatch =
|
||||
!search ||
|
||||
[contract.title, contract.provider, contract.notes, contract.category]
|
||||
[contract.title, contract.provider, contract.notes, contract.category, translateCategoryName(contract.category ?? "", i18n.language)]
|
||||
.filter(Boolean)
|
||||
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
@@ -130,7 +131,7 @@ export default function ContractsList() {
|
||||
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
||||
{(categoryOptions ?? []).map((item) => (
|
||||
<MenuItem key={item.id} value={item.name}>
|
||||
{item.name}
|
||||
{translateCategoryName(item.name, i18n.language) || item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
@@ -190,7 +191,11 @@ export default function ContractsList() {
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{contract.provider ?? "–"}</TableCell>
|
||||
<TableCell>{contract.category ?? "–"}</TableCell>
|
||||
<TableCell>
|
||||
{contract.category
|
||||
? translateCategoryName(contract.category, i18n.language) || contract.category
|
||||
: "–"}
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
|
||||
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ import PageHeader from "../components/PageHeader";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
@@ -55,6 +57,8 @@ interface HealthResponse {
|
||||
type FormValues = {
|
||||
paperlessBaseUrl: string;
|
||||
paperlessExternalUrl: string;
|
||||
appExternalUrl: string;
|
||||
appLocale: string;
|
||||
paperlessToken: string;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
@@ -76,6 +80,8 @@ type FormValues = {
|
||||
const defaultValues: FormValues = {
|
||||
paperlessBaseUrl: "",
|
||||
paperlessExternalUrl: "",
|
||||
appExternalUrl: "",
|
||||
appLocale: "de",
|
||||
paperlessToken: "",
|
||||
schedulerIntervalMinutes: 60,
|
||||
alertDaysBefore: 30,
|
||||
@@ -97,7 +103,7 @@ const defaultValues: FormValues = {
|
||||
export default function SettingsPage() {
|
||||
const { authEnabled } = useAuth();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -159,6 +165,8 @@ export default function SettingsPage() {
|
||||
const values: FormValues = {
|
||||
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
|
||||
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
|
||||
appExternalUrl: settingsData.values.appExternalUrl ?? "",
|
||||
appLocale: settingsData.values.appLocale ?? "de",
|
||||
paperlessToken: "",
|
||||
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
|
||||
alertDaysBefore: settingsData.values.alertDaysBefore,
|
||||
@@ -223,7 +231,8 @@ export default function SettingsPage() {
|
||||
await refetchCategories();
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setNewCategoryName("");
|
||||
showMessage(t("settings.categoryCreated", { name: category.name }), "success");
|
||||
const displayName = translateCategoryName(category.name, i18n.language) || category.name;
|
||||
showMessage(t("settings.categoryCreated", { name: displayName }), "success");
|
||||
},
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryCreateError"), "error")
|
||||
});
|
||||
@@ -238,6 +247,38 @@ export default function SettingsPage() {
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryDeleteError"), "error")
|
||||
});
|
||||
|
||||
const trimmedCategoryName = newCategoryName.trim();
|
||||
const categoryExists = useMemo(() => {
|
||||
if (!trimmedCategoryName) {
|
||||
return false;
|
||||
}
|
||||
if (!categories) {
|
||||
return false;
|
||||
}
|
||||
const normalizedInput = trimmedCategoryName.toLowerCase();
|
||||
const canonicalInput = getCanonicalDefaultCategoryName(trimmedCategoryName)?.toLowerCase() ?? null;
|
||||
return categories.some((category) => {
|
||||
const normalizedName = category.name.toLowerCase();
|
||||
return (
|
||||
normalizedName === normalizedInput ||
|
||||
(canonicalInput ? normalizedName === canonicalInput : false)
|
||||
);
|
||||
});
|
||||
}, [categories, trimmedCategoryName]);
|
||||
|
||||
const handleAddCategory = useCallback(() => {
|
||||
if (!trimmedCategoryName) {
|
||||
showMessage(t("settings.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
if (categoryExists) {
|
||||
const existingDisplayName = translateCategoryName(trimmedCategoryName, i18n.language) || trimmedCategoryName;
|
||||
showMessage(t("settings.categoryExists", { name: existingDisplayName }), "info");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmedCategoryName);
|
||||
}, [categoryExists, createCategoryMutation, i18n.language, showMessage, t, trimmedCategoryName]);
|
||||
|
||||
const settings = settingsData;
|
||||
const icalSecret = settings?.icalSecret ?? null;
|
||||
const icalUrl = useMemo(() => {
|
||||
@@ -265,6 +306,12 @@ export default function SettingsPage() {
|
||||
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
|
||||
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
|
||||
}
|
||||
if (formValues.appExternalUrl !== initial.appExternalUrl) {
|
||||
payload.appExternalUrl = trimOrNull(formValues.appExternalUrl);
|
||||
}
|
||||
if (formValues.appLocale !== initial.appLocale) {
|
||||
payload.appLocale = formValues.appLocale;
|
||||
}
|
||||
|
||||
if (removePaperlessToken) {
|
||||
payload.paperlessToken = null;
|
||||
@@ -521,19 +568,18 @@ export default function SettingsPage() {
|
||||
label={t("settings.categoryName")}
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddCategory();
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("settings.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
onClick={handleAddCategory}
|
||||
disabled={createCategoryMutation.isPending || !trimmedCategoryName}
|
||||
>
|
||||
{t("settings.categoryAdd")}
|
||||
</Button>
|
||||
@@ -543,7 +589,7 @@ export default function SettingsPage() {
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
label={translateCategoryName(category.name, i18n.language) || category.name}
|
||||
onDelete={() => deleteCategoryMutation.mutate(category.id)}
|
||||
disabled={deleteCategoryMutation.isPending}
|
||||
/>
|
||||
@@ -579,6 +625,23 @@ export default function SettingsPage() {
|
||||
placeholder={t("settings.paperlessExample")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.appExternalUrl")}
|
||||
{...register("appExternalUrl")}
|
||||
placeholder={t("settings.appExternalExample")}
|
||||
fullWidth
|
||||
helperText={t("settings.appExternalUrlHelp")}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label={t("settings.appLocaleLabel")}
|
||||
{...register("appLocale")}
|
||||
fullWidth
|
||||
helperText={t("settings.appLocaleHelp")}
|
||||
>
|
||||
<MenuItem value="de">{t("settings.appLocaleGerman")}</MenuItem>
|
||||
<MenuItem value="en">{t("settings.appLocaleEnglish")}</MenuItem>
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
|
||||
|
||||
10
frontend/src/types/shared.d.ts
vendored
Normal file
10
frontend/src/types/shared.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module "@shared/defaultCategories.json" {
|
||||
interface DefaultCategory {
|
||||
key: string;
|
||||
de: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
const categories: DefaultCategory[];
|
||||
export default categories;
|
||||
}
|
||||
45
frontend/src/utils/categories.ts
Normal file
45
frontend/src/utils/categories.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import defaultCategories from "@shared/defaultCategories.json";
|
||||
|
||||
type DefaultCategory = {
|
||||
key: string;
|
||||
de: string;
|
||||
en: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CATEGORY_DATA = defaultCategories as DefaultCategory[];
|
||||
|
||||
function normalize(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const translationsByNormalizedName = new Map<string, DefaultCategory>();
|
||||
for (const category of DEFAULT_CATEGORY_DATA) {
|
||||
translationsByNormalizedName.set(normalize(category.de), category);
|
||||
translationsByNormalizedName.set(normalize(category.en), category);
|
||||
}
|
||||
|
||||
export function getCanonicalDefaultCategoryName(input: string): string | null {
|
||||
const normalized = normalize(input);
|
||||
const match = translationsByNormalizedName.get(normalized);
|
||||
return match ? match.de : null;
|
||||
}
|
||||
|
||||
export function translateCategoryName(name: string | null | undefined, language: string): string {
|
||||
if (!name) {
|
||||
return "";
|
||||
}
|
||||
const normalized = normalize(name);
|
||||
const match = translationsByNormalizedName.get(normalized);
|
||||
if (!match) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (language.startsWith("de")) {
|
||||
return match.de;
|
||||
}
|
||||
if (language.startsWith("en")) {
|
||||
return match.en;
|
||||
}
|
||||
// For other languages we fall back to English to keep labels readable.
|
||||
return match.en;
|
||||
}
|
||||
Reference in New Issue
Block a user