mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-07 02:18:07 +01:00
Classification Model UI Refactor (#20602)
* Add cutoff for object classification * Add selector for classifiction model type * Improve model selection view * Clean up design of classification card * Tweaks * Adjust button colors * Improvements to gradients and making face library consistent * Add basic classification model wizard * Use relative coordinates * Properly get resolution * Clean up exports * Cleanup * Cleanup * Update to use pre-defined component for image shadow * Refactor image grouping * Clean up mobile * Clean up decision logic * Remove max check on classification objects * Increase default number of faces shown * Cleanup * Improve mobile layout * Clenaup * Update vocabulary * Fix layout * Fix page * Cleanup * Choose last item for unknown objects * Move explore button * Cleanup grid * Cleanup classification * Cleanup grid * Cleanup * Set transparency * Set unknown * Don't filter all configs * Check length
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
ClassificationThreshold,
|
||||
} from "@/types/classification";
|
||||
import { Event } from "@/types/event";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { forwardRef, useMemo, useRef, useState } from "react";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
@@ -14,7 +14,24 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
MobilePageDescription,
|
||||
MobilePageHeader,
|
||||
MobilePageTitle,
|
||||
MobilePageTrigger,
|
||||
} from "../mobile/MobilePage";
|
||||
|
||||
type ClassificationCardProps = {
|
||||
className?: string;
|
||||
@@ -24,20 +41,28 @@ type ClassificationCardProps = {
|
||||
selected: boolean;
|
||||
i18nLibrary: string;
|
||||
showArea?: boolean;
|
||||
count?: number;
|
||||
onClick: (data: ClassificationItemData, meta: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export function ClassificationCard({
|
||||
className,
|
||||
imgClassName,
|
||||
data,
|
||||
threshold,
|
||||
selected,
|
||||
i18nLibrary,
|
||||
showArea = true,
|
||||
onClick,
|
||||
children,
|
||||
}: ClassificationCardProps) {
|
||||
export const ClassificationCard = forwardRef<
|
||||
HTMLDivElement,
|
||||
ClassificationCardProps
|
||||
>(function ClassificationCard(
|
||||
{
|
||||
className,
|
||||
imgClassName,
|
||||
data,
|
||||
threshold,
|
||||
selected,
|
||||
i18nLibrary,
|
||||
showArea = true,
|
||||
count,
|
||||
onClick,
|
||||
children,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation([i18nLibrary]);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
@@ -72,61 +97,81 @@ export function ClassificationCard({
|
||||
}, [showArea, imageLoaded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex size-full cursor-pointer flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
||||
className,
|
||||
selected
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const isMeta = e.metaKey || e.ctrlKey;
|
||||
if (isMeta) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
onClick(data, isMeta);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick(data, true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg outline outline-[3px]",
|
||||
className,
|
||||
selected
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
"absolute bottom-0 left-0 right-0 top-0 size-full",
|
||||
imgClassName,
|
||||
isMobile && "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full select-none overflow-hidden rounded-lg">
|
||||
<img
|
||||
ref={imgRef}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
className={cn("size-44", imgClassName, isMobile && "w-full")}
|
||||
src={`${baseUrl}${data.filepath}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(data, e.metaKey || e.ctrlKey);
|
||||
}}
|
||||
/>
|
||||
{imageArea != undefined && (
|
||||
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
|
||||
{t("information.pixels", { ns: "common", area: imageArea })}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
src={`${baseUrl}${data.filepath}`}
|
||||
/>
|
||||
<ImageShadowOverlay upperClassName="z-0" lowerClassName="h-[30%] z-0" />
|
||||
{count && (
|
||||
<div className="absolute right-2 top-2 flex flex-row items-center gap-1">
|
||||
<div className="text-gray-200">{count}</div>{" "}
|
||||
<HiSquare2Stack className="text-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
{!count && imageArea != undefined && (
|
||||
<div className="absolute right-1 top-1 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
|
||||
{t("information.pixels", { ns: "common", area: imageArea })}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[50%] bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-0 flex w-full select-none flex-row items-center justify-between gap-2 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start text-white",
|
||||
data.score ? "text-xs" : "text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="smart-capitalize">
|
||||
{data.name == "unknown" ? t("details.unknown") : data.name}
|
||||
</div>
|
||||
{data.score && (
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
scoreStatus == "match" && "text-success",
|
||||
scoreStatus == "potential" && "text-orange-400",
|
||||
scoreStatus == "unknown" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{Math.round(data.score * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="select-none p-2">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||
<div className="smart-capitalize">
|
||||
{data.name == "unknown" ? t("details.unknown") : data.name}
|
||||
</div>
|
||||
{data.score && (
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
scoreStatus == "match" && "text-success",
|
||||
scoreStatus == "potential" && "text-orange-400",
|
||||
scoreStatus == "unknown" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{Math.round(data.score * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-start justify-end gap-5 md:gap-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-start justify-end gap-5 md:gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
type GroupedClassificationCardProps = {
|
||||
group: ClassificationItemData[];
|
||||
@@ -136,7 +181,6 @@ type GroupedClassificationCardProps = {
|
||||
i18nLibrary: string;
|
||||
objectType: string;
|
||||
onClick: (data: ClassificationItemData | undefined) => void;
|
||||
onSelectEvent: (event: Event) => void;
|
||||
children?: (data: ClassificationItemData) => React.ReactNode;
|
||||
};
|
||||
export function GroupedClassificationCard({
|
||||
@@ -145,20 +189,54 @@ export function GroupedClassificationCard({
|
||||
threshold,
|
||||
selectedItems,
|
||||
i18nLibrary,
|
||||
objectType,
|
||||
onClick,
|
||||
onSelectEvent,
|
||||
children,
|
||||
}: GroupedClassificationCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(["views/explore", i18nLibrary]);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
// data
|
||||
|
||||
const allItemsSelected = useMemo(
|
||||
() => group.every((data) => selectedItems.includes(data.filename)),
|
||||
[group, selectedItems],
|
||||
);
|
||||
const bestItem = useMemo<ClassificationItemData | undefined>(() => {
|
||||
let best: undefined | ClassificationItemData = undefined;
|
||||
|
||||
group.forEach((item) => {
|
||||
if (item?.name != undefined && item.name != "none") {
|
||||
if (
|
||||
best?.score == undefined ||
|
||||
(item.score && best.score < item.score)
|
||||
) {
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!best) {
|
||||
return group.at(-1);
|
||||
}
|
||||
|
||||
const bestTyped: ClassificationItemData = best;
|
||||
return {
|
||||
...bestTyped,
|
||||
name: event ? (event.sub_label ?? t("details.unknown")) : bestTyped.name,
|
||||
score: event?.data?.sub_label_score || bestTyped.score,
|
||||
};
|
||||
}, [group, event, t]);
|
||||
|
||||
const bestScoreStatus = useMemo(() => {
|
||||
if (!bestItem?.score || !threshold) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (bestItem.score >= threshold.recognition) {
|
||||
return "match";
|
||||
} else if (bestItem.score >= threshold.unknown) {
|
||||
return "potential";
|
||||
} else {
|
||||
return "unknown";
|
||||
}
|
||||
}, [bestItem, threshold]);
|
||||
|
||||
const time = useMemo(() => {
|
||||
const item = group[0];
|
||||
@@ -170,94 +248,143 @@ export function GroupedClassificationCard({
|
||||
return item.timestamp * 1000;
|
||||
}, [group]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-2 rounded-lg bg-card p-2 outline outline-[3px]",
|
||||
isMobile && "w-full",
|
||||
allItemsSelected
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (selectedItems.length) {
|
||||
onClick(undefined);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick(undefined);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="select-none smart-capitalize">
|
||||
{getTranslatedLabel(objectType)}
|
||||
{event?.sub_label
|
||||
? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)`
|
||||
: ": " + t("details.unknown")}
|
||||
</div>
|
||||
{time && (
|
||||
<TimeAgo
|
||||
className="text-sm text-secondary-foreground"
|
||||
time={time}
|
||||
dense
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{event && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
<LuSearch className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("details.item.button.viewInExplore", {
|
||||
ns: "views/explore",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
if (!bestItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"gap-2",
|
||||
isDesktop
|
||||
? "flex flex-row flex-wrap"
|
||||
: "grid grid-cols-2 sm:grid-cols-5 lg:grid-cols-6",
|
||||
)}
|
||||
const Overlay = isDesktop ? Dialog : MobilePage;
|
||||
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
|
||||
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
||||
const Content = isDesktop ? DialogContent : MobilePageContent;
|
||||
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
|
||||
const ContentDescription = isDesktop
|
||||
? DialogDescription
|
||||
: MobilePageDescription;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClassificationCard
|
||||
data={bestItem}
|
||||
threshold={threshold}
|
||||
selected={selectedItems.includes(bestItem.filename)}
|
||||
i18nLibrary={i18nLibrary}
|
||||
count={group.length}
|
||||
onClick={(_, meta) => {
|
||||
if (meta || selectedItems.length > 0) {
|
||||
onClick(undefined);
|
||||
} else {
|
||||
setDetailOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Overlay
|
||||
open={detailOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDetailOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.map((data: ClassificationItemData) => (
|
||||
<ClassificationCard
|
||||
key={data.filename}
|
||||
data={data}
|
||||
threshold={threshold}
|
||||
selected={
|
||||
allItemsSelected ? false : selectedItems.includes(data.filename)
|
||||
}
|
||||
i18nLibrary={i18nLibrary}
|
||||
onClick={(data, meta) => {
|
||||
if (meta || selectedItems.length > 0) {
|
||||
onClick(data);
|
||||
} else if (event) {
|
||||
onSelectEvent(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children?.(data)}
|
||||
</ClassificationCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Trigger asChild></Trigger>
|
||||
<Content
|
||||
className={cn(
|
||||
"",
|
||||
isDesktop && "min-w-[50%] max-w-[65%]",
|
||||
isMobile && "flex flex-col",
|
||||
)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<>
|
||||
<Header
|
||||
className={cn(
|
||||
"mx-2 flex flex-row items-center gap-4",
|
||||
isMobile && "flex-shrink-0",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<ContentTitle
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-normal capitalize",
|
||||
isMobile && "px-2",
|
||||
)}
|
||||
>
|
||||
{event?.sub_label ? event.sub_label : t("details.unknown")}
|
||||
{event?.sub_label && (
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
bestScoreStatus == "match" && "text-success",
|
||||
bestScoreStatus == "potential" && "text-orange-400",
|
||||
bestScoreStatus == "unknown" && "text-danger",
|
||||
)}
|
||||
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div>
|
||||
)}
|
||||
</ContentTitle>
|
||||
<ContentDescription className={cn("", isMobile && "px-2")}>
|
||||
{time && (
|
||||
<TimeAgo
|
||||
className="text-sm text-secondary-foreground"
|
||||
time={time}
|
||||
dense
|
||||
/>
|
||||
)}
|
||||
</ContentDescription>
|
||||
</div>
|
||||
{isDesktop && (
|
||||
<div className="flex flex-row justify-between">
|
||||
{event && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
<LuSearch className="size-4 text-secondary-foreground" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("details.item.button.viewInExplore", {
|
||||
ns: "views/explore",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
|
||||
isDesktop && "p-2",
|
||||
isMobile && "scrollbar-container flex-1 overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
{group.map((data: ClassificationItemData) => (
|
||||
<div key={data.filename} className="aspect-square w-full">
|
||||
<ClassificationCard
|
||||
data={data}
|
||||
threshold={threshold}
|
||||
selected={false}
|
||||
i18nLibrary={i18nLibrary}
|
||||
onClick={(data, meta) => {
|
||||
if (meta || selectedItems.length > 0) {
|
||||
onClick(data);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children?.(data)}
|
||||
</ClassificationCard>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</Content>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shareOrCopy } from "@/utils/browserUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
|
||||
type ExportProps = {
|
||||
className: string;
|
||||
@@ -145,7 +146,7 @@ export default function ExportCard({
|
||||
<>
|
||||
{exportedRecording.thumb_path.length > 0 ? (
|
||||
<img
|
||||
className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl"
|
||||
className="absolute inset-0 aspect-video size-full rounded-lg object-cover md:rounded-2xl"
|
||||
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
@@ -224,10 +225,9 @@ export default function ExportCard({
|
||||
{loading && (
|
||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] rounded-lg bg-gradient-to-t from-black/60 to-transparent md:rounded-2xl">
|
||||
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white smart-capitalize">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
</div>
|
||||
<ImageShadowOverlay />
|
||||
<div className="absolute bottom-2 left-3 flex h-full items-end justify-between text-white smart-capitalize">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StepIndicator from "../indicators/StepIndicator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
const STEPS = [
|
||||
"classificationWizard.steps.nameAndDefine",
|
||||
"classificationWizard.steps.stateArea",
|
||||
"classificationWizard.steps.chooseExamples",
|
||||
"classificationWizard.steps.train",
|
||||
];
|
||||
|
||||
type ClassificationModelWizardDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
export default function ClassificationModelWizardDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: ClassificationModelWizardDialogProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
|
||||
// step management
|
||||
const [currentStep, _] = useState(0);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-h-[90dvh] max-w-4xl overflow-y-auto"
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<StepIndicator
|
||||
steps={STEPS}
|
||||
currentStep={currentStep}
|
||||
variant="dots"
|
||||
className="mb-4 justify-start"
|
||||
/>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("wizard.title")}</DialogTitle>
|
||||
{currentStep === 0 && (
|
||||
<DialogDescription>{t("wizard.description")}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="pb-4">
|
||||
<div className="size-full"></div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -20,15 +20,14 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import TextEntryDialog from "./dialog/TextEntryDialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { MdCategory } from "react-icons/md";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
type ClassificationSelectionDialogProps = {
|
||||
className?: string;
|
||||
@@ -97,7 +96,7 @@ export default function ClassificationSelectionDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ?? ""}>
|
||||
<div className={className ?? "flex"}>
|
||||
{newClass && (
|
||||
<TextEntryDialog
|
||||
open={true}
|
||||
@@ -128,23 +127,22 @@ export default function ClassificationSelectionDialog({
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
>
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
<LuPlus />
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
{classes.sort().map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
<MdCategory />
|
||||
{category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function FaceSelectionDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ?? ""}>
|
||||
<div className={className ?? "flex"}>
|
||||
{newFace && (
|
||||
<TextEntryDialog
|
||||
open={true}
|
||||
|
||||
27
web/src/components/overlay/ImageShadowOverlay.tsx
Normal file
27
web/src/components/overlay/ImageShadowOverlay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImageShadowOverlayProps = {
|
||||
upperClassName?: string;
|
||||
lowerClassName?: string;
|
||||
};
|
||||
export function ImageShadowOverlay({
|
||||
upperClassName,
|
||||
lowerClassName,
|
||||
}: ImageShadowOverlayProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export default function TrainFilterDialog({
|
||||
moreFiltersSelected ? "text-white" : "text-secondary-foreground",
|
||||
)}
|
||||
/>
|
||||
{isDesktop && t("more")}
|
||||
{isDesktop && t("filter")}
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
@@ -122,7 +122,7 @@ export default function TrainFilterDialog({
|
||||
return (
|
||||
<PlatformAwareSheet
|
||||
trigger={trigger}
|
||||
title={t("more")}
|
||||
title={t("filter")}
|
||||
content={content}
|
||||
contentClassName={cn(
|
||||
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
|
||||
|
||||
@@ -6,6 +6,7 @@ import MSEPlayer from "./MsePlayer";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
|
||||
type LivePlayerProps = {
|
||||
className?: string;
|
||||
@@ -76,8 +77,7 @@ export default function BirdseyeLivePlayer({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
|
||||
<ImageShadowOverlay />
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PlayerStats } from "./PlayerStats";
|
||||
import { LuVideoOff } from "react-icons/lu";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||
@@ -328,10 +329,7 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
|
||||
</>
|
||||
<ImageShadowOverlay />
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
|
||||
@@ -107,7 +107,7 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-4 w-4 text-secondary-foreground" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
Reference in New Issue
Block a user