Compare commits

...

9 Commits

Author SHA1 Message Date
Josh Hawkins
a2639b54bd yaml list 2025-12-10 09:31:53 -06:00
Josh Hawkins
1114c9c903 lpr docs tweaks 2025-12-10 09:28:52 -06:00
Josh Hawkins
c79f1a9038 add proxmox ballooning comment 2025-12-10 09:21:20 -06:00
Nicolas Mowen
c5c7a08c4d Disable iOS image dragging for classification card 2025-12-10 07:30:59 -07:00
Josh Hawkins
97ba7a6b78 add select all link to face library, classification, and explore 2025-12-10 08:28:48 -06:00
Josh Hawkins
44c92e84cd fix explore context menu from blocking pointer events on the body element after dialog close
applying modal=false to the menu (not to the dialog) to fix this in the same way as elsewhere in the codebase
2025-12-09 22:09:29 -06:00
Nicolas Mowen
d33b0ac337 Fix classification reset filter 2025-12-09 15:23:28 -07:00
Josh Hawkins
9b85e66608 only allow admins to save annotation offset 2025-12-09 14:41:56 -06:00
Josh Hawkins
8d5349ca85 conditionally display actions for admin role only 2025-12-09 14:37:38 -06:00
15 changed files with 207 additions and 105 deletions

View File

@ -374,9 +374,19 @@ Use `match_distance` to allow small character mismatches. Alternatively, define
Start with ["Why isn't my license plate being detected and recognized?"](#why-isnt-my-license-plate-being-detected-and-recognized). If you are still having issues, work through these steps. Start with ["Why isn't my license plate being detected and recognized?"](#why-isnt-my-license-plate-being-detected-and-recognized). If you are still having issues, work through these steps.
1. Enable debug logs to see exactly what Frigate is doing. 1. Start with a simplified LPR config.
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only keep this enabled when necessary. - Remove or comment out everything in your LPR config, including `min_area`, `min_plate_length`, `format`, `known_plates`, or `enhancement` values so that the only values left are `enabled` and `debug_save_plates`. This will run LPR with Frigate's default values.
```yaml
lpr:
enabled: true
debug_save_plates: true
```
2. Enable debug logs to see exactly what Frigate is doing.
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only keep this enabled when necessary. Restart Frigate after this change.
```yaml ```yaml
logger: logger:
@ -385,7 +395,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
frigate.data_processing.common.license_plate: debug frigate.data_processing.common.license_plate: debug
``` ```
2. Ensure your plates are being _detected_. 3. Ensure your plates are being _detected_.
If you are using a Frigate+ or `license_plate` detecting model: If you are using a Frigate+ or `license_plate` detecting model:
@ -398,7 +408,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
- Watch the debug logs for messages from the YOLOv9 plate detector. - Watch the debug logs for messages from the YOLOv9 plate detector.
- You may need to adjust your `detection_threshold` if your plates are not being detected. - You may need to adjust your `detection_threshold` if your plates are not being detected.
3. Ensure the characters on detected plates are being _recognized_. 4. Ensure the characters on detected plates are being _recognized_.
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear. - Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working. - Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working.

View File

@ -135,6 +135,7 @@ Finally, configure [hardware object detection](/configuration/object_detectors#h
### MemryX MX3 ### MemryX MX3
The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVMe SSD), and supports a variety of configurations: The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVMe SSD), and supports a variety of configurations:
- x86 (Intel/AMD) PCs - x86 (Intel/AMD) PCs
- Raspberry Pi 5 - Raspberry Pi 5
- Orange Pi 5 Plus/Max - Orange Pi 5 Plus/Max
@ -142,7 +143,6 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
#### Configuration #### Configuration
#### Installation #### Installation
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html). To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html).
@ -156,7 +156,7 @@ Then follow these steps for installing the correct driver/runtime configuration:
#### Setup #### Setup
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable` To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file: Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
@ -173,7 +173,7 @@ In your `docker-compose.yml`, also add:
privileged: true privileged: true
volumes: volumes:
/run/mxa_manager:/run/mxa_manager - /run/mxa_manager:/run/mxa_manager
``` ```
If you can't use Docker Compose, you can run the container with something similar to this: If you can't use Docker Compose, you can run the container with something similar to this:
@ -411,7 +411,7 @@ To install make sure you have the [community app plugin here](https://forums.unr
## Proxmox ## Proxmox
[According to Proxmox documentation](https://pve.proxmox.com/pve-docs/pve-admin-guide.html#chapter_pct) it is recommended that you run application containers like Frigate inside a Proxmox QEMU VM. This will give you all the advantages of application containerization, while also providing the benefits that VMs offer, such as strong isolation from the host and the ability to live-migrate, which otherwise isnt possible with containers. [According to Proxmox documentation](https://pve.proxmox.com/pve-docs/pve-admin-guide.html#chapter_pct) it is recommended that you run application containers like Frigate inside a Proxmox QEMU VM. This will give you all the advantages of application containerization, while also providing the benefits that VMs offer, such as strong isolation from the host and the ability to live-migrate, which otherwise isnt possible with containers. Ensure that ballooning is **disabled**, especially if you are passing through a GPU to the VM.
:::warning :::warning

View File

@ -52,6 +52,7 @@
}, },
"selected_one": "{{count}} selected", "selected_one": "{{count}} selected",
"selected_other": "{{count}} selected", "selected_other": "{{count}} selected",
"select_all": "All",
"camera": "Camera", "camera": "Camera",
"detected": "detected", "detected": "detected",
"normalActivity": "Normal", "normalActivity": "Normal",

View File

@ -29,6 +29,7 @@
}, },
"train": { "train": {
"title": "Recent Recognitions", "title": "Recent Recognitions",
"titleShort": "Recent",
"aria": "Select recent recognitions", "aria": "Select recent recognitions",
"empty": "There are no recent face recognition attempts" "empty": "There are no recent face recognition attempts"
}, },

View File

@ -7,7 +7,7 @@ import {
} from "@/types/classification"; } from "@/types/classification";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { forwardRef, useMemo, useRef, useState } from "react"; import { forwardRef, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect"; import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@ -127,6 +127,15 @@ export const ClassificationCard = forwardRef<
imgClassName, imgClassName,
isMobile && "w-full", isMobile && "w-full",
)} )}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
loading="lazy" loading="lazy"
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
src={`${baseUrl}${data.filepath}`} src={`${baseUrl}${data.filepath}`}

View File

@ -19,6 +19,7 @@ import {
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
type ReviewActionGroupProps = { type ReviewActionGroupProps = {
selectedReviews: ReviewSegment[]; selectedReviews: ReviewSegment[];
@ -33,6 +34,7 @@ export default function ReviewActionGroup({
pullLatestData, pullLatestData,
}: ReviewActionGroupProps) { }: ReviewActionGroupProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const isAdmin = useIsAdmin();
const onClearSelected = useCallback(() => { const onClearSelected = useCallback(() => {
setSelectedReviews([]); setSelectedReviews([]);
}, [setSelectedReviews]); }, [setSelectedReviews]);
@ -185,21 +187,23 @@ export default function ReviewActionGroup({
</div> </div>
)} )}
</Button> </Button>
<Button {isAdmin && (
className="flex items-center gap-2 p-2" <Button
aria-label={t("button.delete", { ns: "common" })} className="flex items-center gap-2 p-2"
size="sm" aria-label={t("button.delete", { ns: "common" })}
onClick={handleDelete} size="sm"
> onClick={handleDelete}
<HiTrash className="text-secondary-foreground" /> >
{isDesktop && ( <HiTrash className="text-secondary-foreground" />
<div className="text-primary"> {isDesktop && (
{bypassDialog <div className="text-primary">
? t("recording.button.deleteNow") {bypassDialog
: t("button.delete", { ns: "common" })} ? t("recording.button.deleteNow")
</div> : t("button.delete", { ns: "common" })}
)} </div>
</Button> )}
</Button>
)}
</div> </div>
</div> </div>
</> </>

View File

@ -16,18 +16,24 @@ import {
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { toast } from "sonner"; import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useIsAdmin } from "@/hooks/use-is-admin";
type SearchActionGroupProps = { type SearchActionGroupProps = {
selectedObjects: string[]; selectedObjects: string[];
setSelectedObjects: (ids: string[]) => void; setSelectedObjects: (ids: string[]) => void;
pullLatestData: () => void; pullLatestData: () => void;
onSelectAllObjects: () => void;
totalItems: number;
}; };
export default function SearchActionGroup({ export default function SearchActionGroup({
selectedObjects, selectedObjects,
setSelectedObjects, setSelectedObjects,
pullLatestData, pullLatestData,
onSelectAllObjects,
totalItems,
}: SearchActionGroupProps) { }: SearchActionGroupProps) {
const { t } = useTranslation(["components/filter"]); const { t } = useTranslation(["components/filter"]);
const isAdmin = useIsAdmin();
const onClearSelected = useCallback(() => { const onClearSelected = useCallback(() => {
setSelectedObjects([]); setSelectedObjects([]);
}, [setSelectedObjects]); }, [setSelectedObjects]);
@ -122,24 +128,37 @@ export default function SearchActionGroup({
> >
{t("button.unselect", { ns: "common" })} {t("button.unselect", { ns: "common" })}
</div> </div>
</div> {selectedObjects.length < totalItems && (
<div className="flex items-center gap-1 md:gap-2"> <>
<Button <div className="p-1">{"|"}</div>
className="flex items-center gap-2 p-2" <div
aria-label={t("button.delete", { ns: "common" })} className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
size="sm" onClick={onSelectAllObjects}
onClick={handleDelete} >
> {t("select_all", { ns: "views/events" })}
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</div> </div>
)} </>
</Button> )}
</div> </div>
{isAdmin && (
<div className="flex items-center gap-1 md:gap-2">
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
</div> </div>
</> </>
); );

View File

@ -31,6 +31,7 @@ import {
import useSWR from "swr"; import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import BlurredIconButton from "../button/BlurredIconButton"; import BlurredIconButton from "../button/BlurredIconButton";
import { useIsAdmin } from "@/hooks/use-is-admin";
type SearchResultActionsProps = { type SearchResultActionsProps = {
searchResult: SearchResult; searchResult: SearchResult;
@ -52,6 +53,7 @@ export default function SearchResultActions({
children, children,
}: SearchResultActionsProps) { }: SearchResultActionsProps) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -137,7 +139,8 @@ export default function SearchResultActions({
<span>{t("itemMenu.findSimilar.label")}</span> <span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem> </MenuItem>
)} )}
{config?.semantic_search?.enabled && {isAdmin &&
config?.semantic_search?.enabled &&
searchResult.data.type == "object" && ( searchResult.data.type == "object" && (
<MenuItem <MenuItem
aria-label={t("itemMenu.addTrigger.aria")} aria-label={t("itemMenu.addTrigger.aria")}
@ -146,12 +149,14 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span> <span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem> </MenuItem>
)} )}
<MenuItem {isAdmin && (
aria-label={t("itemMenu.deleteTrackedObject.label")} <MenuItem
onClick={() => setDeleteDialogOpen(true)} aria-label={t("itemMenu.deleteTrackedObject.label")}
> onClick={() => setDeleteDialogOpen(true)}
<span>{t("button.delete", { ns: "common" })}</span> >
</MenuItem> <span>{t("button.delete", { ns: "common" })}</span>
</MenuItem>
)}
</> </>
); );
@ -184,7 +189,7 @@ export default function SearchResultActions({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{isContextMenu ? ( {isContextMenu ? (
<ContextMenu> <ContextMenu modal={false}>
<ContextMenuTrigger>{children}</ContextMenuTrigger> <ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>{menuItems}</ContextMenuContent> <ContextMenuContent>{menuItems}</ContextMenuContent>
</ContextMenu> </ContextMenu>

View File

@ -10,6 +10,7 @@ import { Trans, useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu"; import { LuInfo } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useIsAdmin } from "@/hooks/use-is-admin";
type Props = { type Props = {
className?: string; className?: string;
@ -17,6 +18,7 @@ type Props = {
export default function AnnotationOffsetSlider({ className }: Props) { export default function AnnotationOffsetSlider({ className }: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream(); const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin();
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -101,11 +103,13 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<Button size="sm" variant="ghost" onClick={reset}> <Button size="sm" variant="ghost" onClick={reset}>
{t("button.reset", { ns: "common" })} {t("button.reset", { ns: "common" })}
</Button> </Button>
<Button size="sm" onClick={save} disabled={isSaving}> {isAdmin && (
{isSaving <Button size="sm" onClick={save} disabled={isSaving}>
? t("button.saving", { ns: "common" }) {isSaving
: t("button.save", { ns: "common" })} ? t("button.saving", { ns: "common" })
</Button> : t("button.save", { ns: "common" })}
</Button>
)}
</div> </div>
</div> </div>
<div <div

View File

@ -24,6 +24,7 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { useIsAdmin } from "@/hooks/use-is-admin";
type AnnotationSettingsPaneProps = { type AnnotationSettingsPaneProps = {
event: Event; event: Event;
@ -36,6 +37,7 @@ export function AnnotationSettingsPane({
setAnnotationOffset, setAnnotationOffset,
}: AnnotationSettingsPaneProps) { }: AnnotationSettingsPaneProps) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
@ -201,22 +203,24 @@ export function AnnotationSettingsPane({
> >
{t("button.apply", { ns: "common" })} {t("button.apply", { ns: "common" })}
</Button> </Button>
<Button {isAdmin && (
variant="select" <Button
aria-label={t("button.save", { ns: "common" })} variant="select"
disabled={isLoading} aria-label={t("button.save", { ns: "common" })}
className="flex flex-1" disabled={isLoading}
type="submit" className="flex flex-1"
> type="submit"
{isLoading ? ( >
<div className="flex flex-row items-center gap-2"> {isLoading ? (
<ActivityIndicator /> <div className="flex flex-row items-center gap-2">
<span>{t("button.saving", { ns: "common" })}</span> <ActivityIndicator />
</div> <span>{t("button.saving", { ns: "common" })}</span>
) : ( </div>
t("button.save", { ns: "common" }) ) : (
)} t("button.save", { ns: "common" })
</Button> )}
</Button>
)}
</div> </div>
</div> </div>
</form> </form>

View File

@ -15,6 +15,7 @@ import {
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useIsAdmin } from "@/hooks/use-is-admin";
type Props = { type Props = {
search: SearchResult | Event; search: SearchResult | Event;
@ -35,6 +36,7 @@ export default function DetailActionsMenu({
const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
const navigate = useNavigate(); const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const isAdmin = useIsAdmin();
const clipTimeRange = useMemo(() => { const clipTimeRange = useMemo(() => {
const startTime = (search.start_time ?? 0) - REVIEW_PADDING; const startTime = (search.start_time ?? 0) - REVIEW_PADDING;
@ -130,22 +132,24 @@ export default function DetailActionsMenu({
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{config?.semantic_search.enabled && search.data.type == "object" && ( {isAdmin &&
<DropdownMenuItem config?.semantic_search.enabled &&
onClick={() => { search.data.type == "object" && (
setIsOpen(false); <DropdownMenuItem
setTimeout(() => { onClick={() => {
navigate( setIsOpen(false);
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`, setTimeout(() => {
); navigate(
}, 0); `/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
}} );
> }, 0);
<div className="flex cursor-pointer items-center gap-2"> }}
<span>{t("itemMenu.addTrigger.label")}</span> >
</div> <div className="flex cursor-pointer items-center gap-2">
</DropdownMenuItem> <span>{t("itemMenu.addTrigger.label")}</span>
)} </div>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenu> </DropdownMenu>

View File

@ -97,20 +97,9 @@ export default function TrainFilterDialog({
<Button <Button
aria-label={t("reset.label")} aria-label={t("reset.label")}
onClick={() => { onClick={() => {
setCurrentFilter((prevFilter) => ({ const resetFilter: TrainFilter = {};
...prevFilter, setCurrentFilter(resetFilter);
time_range: undefined, onUpdateFilter(resetFilter);
zones: undefined,
sub_labels: undefined,
search_type: undefined,
min_score: undefined,
max_score: undefined,
min_speed: undefined,
max_speed: undefined,
has_snapshot: undefined,
has_clip: undefined,
recognized_license_plate: undefined,
}));
}} }}
> >
{t("button.reset", { ns: "common" })} {t("button.reset", { ns: "common" })}

View File

@ -52,7 +52,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop, isMobileOnly } from "react-device-detect";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { import {
LuFolderCheck, LuFolderCheck,
@ -370,10 +370,10 @@ export default function FaceLibrary() {
/> />
{selectedFaces?.length > 0 ? ( {selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground"> <div className="mx-1 flex w-auto items-center justify-center text-sm text-muted-foreground">
<div className="p-1"> <div className="p-1">
{t("selected", { {t("selected", {
ns: "views/event", ns: "views/events",
count: selectedFaces.length, count: selectedFaces.length,
})} })}
</div> </div>
@ -384,6 +384,24 @@ export default function FaceLibrary() {
> >
{t("button.unselect", { ns: "common" })} {t("button.unselect", { ns: "common" })}
</div> </div>
{selectedFaces.length <
(pageToggle === "train"
? trainImages.length
: faceImages.length) && (
<>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() =>
setSelectedFaces([
...(pageToggle === "train" ? trainImages : faceImages),
])
}
>
{t("select_all", { ns: "views/events" })}
</div>
</>
)}
</div> </div>
<Button <Button
className="flex gap-2" className="flex gap-2"
@ -482,6 +500,18 @@ function LibrarySelector({
[renameFace], [renameFace],
); );
const pageTitle = useMemo(() => {
if (pageToggle != "train") {
return pageToggle;
}
if (isMobileOnly) {
return t("train.titleShort");
}
return t("train.title");
}, [pageToggle, t]);
return ( return (
<> <>
<Dialog <Dialog
@ -532,7 +562,7 @@ function LibrarySelector({
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize"> <Button className="flex justify-between smart-capitalize">
{pageToggle == "train" ? t("train.title") : pageToggle} {pageTitle}
<span className="ml-2 text-primary-variant"> <span className="ml-2 text-primary-variant">
({(pageToggle && faceData?.[pageToggle]?.length) || 0}) ({(pageToggle && faceData?.[pageToggle]?.length) || 0})
</span> </span>

View File

@ -421,10 +421,10 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
isMobileOnly && "justify-between", isMobileOnly && "justify-between",
)} )}
> >
<div className="flex w-48 items-center justify-center text-sm text-muted-foreground"> <div className="flex w-auto items-center justify-center text-sm text-muted-foreground md:w-auto">
<div className="p-1"> <div className="p-1">
{t("selected", { {t("selected", {
ns: "views/event", ns: "views/events",
count: selectedImages.length, count: selectedImages.length,
})} })}
</div> </div>
@ -435,6 +435,26 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
> >
{t("button.unselect", { ns: "common" })} {t("button.unselect", { ns: "common" })}
</div> </div>
{selectedImages.length <
(pageToggle === "train"
? trainImages?.length || 0
: dataset?.[pageToggle]?.length || 0) && (
<>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() =>
setSelectedImages([
...(pageToggle === "train"
? trainImages || []
: dataset?.[pageToggle] || []),
])
}
>
{t("select_all", { ns: "views/events" })}
</div>
</>
)}
</div> </div>
<Button <Button
className="flex gap-2" className="flex gap-2"

View File

@ -572,6 +572,8 @@ export default function SearchView({
selectedObjects={selectedObjects} selectedObjects={selectedObjects}
setSelectedObjects={setSelectedObjects} setSelectedObjects={setSelectedObjects}
pullLatestData={refresh} pullLatestData={refresh}
onSelectAllObjects={onSelectAllObjects}
totalItems={uniqueResults.length}
/> />
</div> </div>
)} )}