feat: add i18n (translation/localization) (#16877)

* Translation module init

* Add more i18n keys

* fix: fix string wrong

* refactor: use namespace translation file

* chore: add more translation key

* fix: fix some page name error

* refactor: change Trans tag for t function

* chore: fix some key not work

* chore: fix SearchFilterDialog i18n key error

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* chore: fix en i18n file filter missing some keys

* chore: add some i18n keys

* chore: add more i18n keys again

* feat: add search page i18n

* feat: add explore model i18n keys

* Update web/src/components/menu/GeneralSettings.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/components/menu/GeneralSettings.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/components/menu/GeneralSettings.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* feat: add more live i18n keys

* feat: add more search setting i18n keys

* fix: remove some comment

* fix: fix some setting page url error

* Update web/src/views/settings/SearchSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* fix: add system missing keys

* fix: update password update i18n keys

* chore: remove outdate translation.json file

* fix: fix exploreSettings error

* chore: add object setting i18n keys

* Update web/src/views/recording/RecordingView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/public/locales/en/components/filter.json

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/components/overlay/ExportDialog.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* feat: add more i18n keys

* fix: fix motionDetectionTuner html node

* feat: add more page i18n keys

* fix: cameraStream i18n keys error

* feat: add Player i18n keys

* feat: add more toast i18n keys

* feat: change explore setting name

* feat: add more document title i18n keys

* feat: add more search i18n keys

* fix: fix accessDenied i18n keys error

* chore: add objectType i18n

* chore: add  inputWithTags i18n

* chore: add SearchFilterDialog i18n

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* chore: add some missing i18n keys

* chore: remove most import { t } from "i18next";

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
GuoQing Liu
2025-03-16 10:36:20 -05:00
committed by GitHub
co-authored by Josh Hawkins
parent db541abed4
commit d34533981f
150 changed files with 6809 additions and 1926 deletions
+73 -73
View File
@@ -51,6 +51,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
import { Trans, useTranslation } from "react-i18next";
type InputWithTagsProps = {
inputFocused: boolean;
@@ -73,6 +74,7 @@ export default function InputWithTags({
setSearch,
allSuggestions,
}: InputWithTagsProps) {
const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -236,12 +238,9 @@ export default function InputWithTags({
filters.after &&
timestamp <= filters.after * 1000
) {
toast.error(
"The 'before' date must be later than the 'after' date.",
{
position: "top-center",
},
);
toast.error(t("filter.toast.error.beforeDateBeLaterAfter"), {
position: "top-center",
});
return;
}
if (
@@ -249,12 +248,9 @@ export default function InputWithTags({
filters.before &&
timestamp >= filters.before * 1000
) {
toast.error(
"The 'after' date must be earlier than the 'before' date.",
{
position: "top-center",
},
);
toast.error(t("afterDatebeEarlierBefore"), {
position: "top-center",
});
return;
}
if (type === "before") {
@@ -274,7 +270,7 @@ export default function InputWithTags({
score > filters.max_score * 100
) {
toast.error(
"The 'min_score' must be less than or equal to the 'max_score'.",
t("filter.toast.error.minScoreMustBeLessOrEqualMaxScore"),
{
position: "top-center",
},
@@ -287,7 +283,7 @@ export default function InputWithTags({
score < filters.min_score * 100
) {
toast.error(
"The 'max_score' must be greater than or equal to the 'min_score'.",
t("filter.toast.error.maxScoreMustBeGreaterOrEqualMinScore"),
{
position: "top-center",
},
@@ -308,7 +304,7 @@ export default function InputWithTags({
speed > filters.max_speed
) {
toast.error(
"The 'min_speed' must be less than or equal to the 'max_speed'.",
t("filter.toast.error.minSpeedMustBeLessOrEqualMaxSpeed"),
{
position: "top-center",
},
@@ -321,7 +317,7 @@ export default function InputWithTags({
speed < filters.min_speed
) {
toast.error(
"The 'max_speed' must be greater than or equal to the 'min_speed'.",
t("filter.toast.error.maxSpeedMustBeGreaterOrEqualMinSpeed"),
{
position: "top-center",
},
@@ -380,7 +376,7 @@ export default function InputWithTags({
setCurrentFilterType(null);
}
},
[filters, setFilters, allSuggestions],
[filters, setFilters, allSuggestions, t],
);
function formatFilterValues(
@@ -408,16 +404,26 @@ export default function InputWithTags({
return Math.round(Number(filterValues) * 100).toString() + "%";
} else if (filterType === "min_speed" || filterType === "max_speed") {
return (
filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph")
filterValues +
" " +
(config?.ui.unit_system == "metric"
? t("unit.speed.kph", { ns: "common" })
: t("unit.speed.mph", { ns: "common" }))
);
} else if (
filterType === "has_clip" ||
filterType === "has_snapshot" ||
filterType === "is_submitted"
) {
return filterValues ? "Yes" : "No";
return filterValues
? t("button.yes", { ns: "common" })
: t("button.no", { ns: "common" });
} else if (filterType === "labels") {
return t(filterValues as string, { ns: "objects" });
} else if (filterType === "search_type") {
return t("filter.searchType." + (filterValues as string));
} else {
return filterValues as string;
return (filterValues as string).replaceAll("_", " ");
}
}
@@ -653,7 +659,7 @@ export default function InputWithTags({
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
placeholder="Search..."
placeholder={t("placeholder.search")}
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
{(search || Object.keys(filters).length > 0) && (
@@ -665,7 +671,7 @@ export default function InputWithTags({
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Clear search</TooltipContent>
<TooltipContent>{t("button.clear")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
@@ -679,7 +685,7 @@ export default function InputWithTags({
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Save search</TooltipContent>
<TooltipContent>{t("button.save")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
@@ -688,12 +694,14 @@ export default function InputWithTags({
<Tooltip>
<TooltipTrigger className="cursor-default">
<MdImageSearch
aria-label="Similarity search active"
aria-label={t("similaritySearch.active")}
className="size-4 text-selected"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Similarity search active</TooltipContent>
<TooltipContent>
{t("similaritySearch.active")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
@@ -702,10 +710,10 @@ export default function InputWithTags({
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label="Filter information"
aria-label={t("button.filterInformation")}
>
<LuFilter
aria-label="Filters active"
aria-label={t("button.filterActive")}
className={cn(
"size-4",
Object.keys(filters).length > 0
@@ -717,43 +725,24 @@ export default function InputWithTags({
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h3 className="font-medium">How to use text filters</h3>
<h3 className="font-medium">{t("filter.tips.title")}</h3>
<p className="text-sm text-muted-foreground">
Filters help you narrow down your search results. Here's how
to use them in the input field:
{t("filter.tips.desc")}
</p>
<ul className="list-disc pl-5 text-sm text-primary-variant">
<li>
Type a filter name followed by a colon (e.g., "cameras:").
</li>
<li>
Select a value from the suggestions or type your own.
</li>
<li>
Use multiple filters by adding them one after another with
a space in between.
</li>
<li>
Date filters (before: and after:) use{" "}
<em>{getIntlDateFormat()}</em> format.
</li>
<li>
Time range filter uses{" "}
<em>
{config?.ui.time_format == "24hour"
<Trans
ns="views/search"
values={{
DateFormat: getIntlDateFormat(),
exampleTime:
config?.ui.time_format == "24hour"
? "15:00-16:00"
: "3:00PM-4:00PM"}{" "}
</em>
format.
</li>
<li>Remove filters by clicking the 'x' next to them.</li>
</ul>
: "3:00PM-4:00PM",
}}
>
filter.tips.desc.step
</Trans>
<p className="text-sm text-muted-foreground">
Example:{" "}
<code className="text-primary">
cameras:front_door label:person before:01012024
time_range:3:00PM-4:00PM
</code>
<Trans ns="views/search">filter.tips.desc.example</Trans>
</p>
</div>
</PopoverContent>
@@ -780,27 +769,27 @@ export default function InputWithTags({
)}
>
{!currentFilterType && inputValue && (
<CommandGroup heading="Search">
<CommandGroup heading={t("search")}>
<CommandItem
className="cursor-pointer"
onSelect={() => handleSearch(inputValue)}
>
<LuSearch className="mr-2 h-4 w-4" />
Search for "{inputValue}"
{t("searchFor", { inputValue })}
</CommandItem>
</CommandGroup>
)}
{(Object.keys(filters).filter((key) => key !== "query").length > 0 ||
isSimilaritySearch) && (
<CommandGroup heading="Active Filters">
<CommandGroup heading={t("filter.header.activeFilters")}>
<div className="my-2 flex flex-wrap gap-2 px-2">
{isSimilaritySearch && (
<span className="inline-flex items-center whitespace-nowrap rounded-full bg-blue-100 px-2 py-0.5 text-sm text-blue-800">
Similarity Search
{t("similaritySearch.title")}
<button
onClick={handleClearSimilarity}
className="ml-1 focus:outline-none"
aria-label="Clear similarity search"
aria-label={t("similaritySearch.clear")}
>
<LuX className="h-3 w-3" />
</button>
@@ -816,8 +805,8 @@ export default function InputWithTags({
key={`${filterType}-${index}`}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
>
{filterType.replaceAll("_", " ")}:{" "}
{value.replaceAll("_", " ")}
{t("filter.label." + filterType)}:{" "}
{formatFilterValues(filterType, value)}
<button
onClick={() =>
removeFilter(filterType as FilterType, value)
@@ -835,10 +824,12 @@ export default function InputWithTags({
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
>
{filterType === "event_id"
? "Tracked Object ID"
? t("trackedObjectId")
: filterType === "is_submitted"
? "Submitted to Frigate+"
: filterType.replaceAll("_", " ")}
? t("features.submittedToFrigatePlus.label", {
ns: "components/filter",
})
: t("filter.label." + filterType)}
: {formatFilterValues(filterType, filterValues)}
<button
onClick={() =>
@@ -863,7 +854,7 @@ export default function InputWithTags({
!inputValue &&
searchHistoryLoaded &&
(searchHistory?.length ?? 0) > 0 && (
<CommandGroup heading="Saved Searches">
<CommandGroup heading={t("savedSearches")}>
{searchHistory?.map((suggestion, index) => (
<CommandItem
key={index}
@@ -884,7 +875,7 @@ export default function InputWithTags({
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Delete saved search</TooltipContent>
<TooltipContent>{t("button.delete")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</CommandItem>
@@ -892,7 +883,11 @@ export default function InputWithTags({
</CommandGroup>
)}
<CommandGroup
heading={currentFilterType ? "Filter Values" : "Filters"}
heading={
currentFilterType
? t("filter.header.currentFilterType")
: t("filter.header.noFilters")
}
>
{filterSuggestions(suggestions)
.filter(
@@ -905,7 +900,12 @@ export default function InputWithTags({
className="cursor-pointer"
onSelect={() => handleSuggestionClick(suggestion)}
>
{currentFilterType
? formatFilterValues(currentFilterType, suggestion)
: t("filter.label." + suggestion)}
{" ("}
{suggestion}
{")"}
</CommandItem>
))}
</CommandGroup>
+22 -12
View File
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type SaveSearchDialogProps = {
existingNames: string[];
@@ -26,15 +27,22 @@ export function SaveSearchDialog({
onClose,
onSave,
}: SaveSearchDialogProps) {
const { t } = useTranslation(["components/dialog"]);
const [searchName, setSearchName] = useState("");
const handleSave = () => {
if (searchName.trim()) {
onSave(searchName.trim());
setSearchName("");
toast.success(`Search (${searchName.trim()}) has been saved.`, {
position: "top-center",
});
toast.success(
t("search.saveSearch.success", {
searchName: searchName.trim(),
}),
{
position: "top-center",
},
);
onClose();
}
};
@@ -54,34 +62,36 @@ export function SaveSearchDialog({
}}
>
<DialogHeader>
<DialogTitle>Save Search</DialogTitle>
<DialogTitle>{t("search.saveSearch.label")}</DialogTitle>
<DialogDescription className="sr-only">
Provide a name for this saved search.
{t("search.saveSearch.desc")}
</DialogDescription>
</DialogHeader>
<Input
value={searchName}
className="text-md"
onChange={(e) => setSearchName(e.target.value)}
placeholder="Enter a name for your search"
placeholder={t("search.saveSearch.placeholder")}
/>
{overwrite && (
<div className="ml-1 text-sm text-danger">
{searchName} already exists. Saving will overwrite the existing
value.
{t("search.saveSearch.overwrite", { searchName })}
</div>
)}
<DialogFooter>
<Button aria-label="Cancel" onClick={onClose}>
Cancel
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onClose}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
onClick={handleSave}
variant="select"
className="mb-2 md:mb-0"
aria-label="Save this search"
aria-label={t("search.saveSearch.button.save.label")}
>
Save
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>