mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-27 06:09:01 +03:00
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:
co-authored by
Josh Hawkins
parent
db541abed4
commit
d34533981f
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user