* match face wizard with camera and classification wizards

* remove review detail dialog and link chip to detail stream in history

* remove footer on explore images and move to overlay

* use consistent overlay button styles

* spacing tweak

* ensure selected ring stays on top of gradients

* fix z-index

* match object lifecycle with details
This commit is contained in:
Josh Hawkins 2025-10-24 12:08:59 -05:00 committed by GitHub
parent 49f5d595ea
commit eb51eb3c9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 341 additions and 209 deletions

View File

@ -1,6 +1,6 @@
{ {
"description": { "description": {
"addFace": "Walk through adding a new collection to the Face Library.", "addFace": "Add a new collection to the Face Library by uploading your first image.",
"placeholder": "Enter a name for this collection", "placeholder": "Enter a name for this collection",
"invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens." "invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens."
}, },

View File

@ -305,7 +305,7 @@ export function GroupedClassificationCard({
<div> <div>
<ContentTitle <ContentTitle
className={cn( className={cn(
"flex items-center gap-1 font-normal capitalize", "flex items-center gap-2 font-normal capitalize",
isMobile && "px-2", isMobile && "px-2",
)} )}
> >

View File

@ -42,11 +42,11 @@ export default function SearchThumbnailFooter({
return ( return (
<div <div
className={cn( className={cn(
"flex w-full flex-row items-center justify-between gap-2", "flex w-full flex-row items-center justify-between gap-2 text-white",
columns > 4 && "items-start sm:flex-col lg:flex-row lg:items-center", columns > 4 && "items-start sm:flex-col lg:flex-row lg:items-center",
)} )}
> >
<div className="flex flex-col items-start text-xs text-primary-variant"> <div className="flex flex-col items-start text-xs text-white/90 drop-shadow-lg">
{searchResult.end_time ? ( {searchResult.end_time ? (
<TimeAgo time={searchResult.start_time * 1000} dense /> <TimeAgo time={searchResult.start_time * 1000} dense />
) : ( ) : (

View File

@ -214,10 +214,14 @@ export default function SearchResultActions({
searchResult.data.type == "object" && ( searchResult.data.type == "object" && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div className="group relative inline-flex items-center justify-center">
{/* blurred circular hover background */}
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<MdImageSearch <MdImageSearch
className="size-5 cursor-pointer text-primary-variant hover:text-primary" className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white"
onClick={findSimilar} onClick={findSimilar}
/> />
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("itemMenu.findSimilar.label")} {t("itemMenu.findSimilar.label")}
@ -233,10 +237,13 @@ export default function SearchResultActions({
!searchResult.plus_id && ( !searchResult.plus_id && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div className="group relative inline-flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<FrigatePlusIcon <FrigatePlusIcon
className="size-5 cursor-pointer text-primary-variant hover:text-primary" className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white"
onClick={showSnapshot} onClick={showSnapshot}
/> />
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("itemMenu.submitToPlus.label")} {t("itemMenu.submitToPlus.label")}
@ -246,7 +253,10 @@ export default function SearchResultActions({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<FiMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" /> <div className="group relative inline-flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<FiMoreVertical className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white" />
</div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent> <DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -102,17 +102,23 @@ export default function CreateFaceWizardDialog({
}} }}
> >
<Content <Content
className={cn("flex flex-col gap-4", isDesktop ? "max-w-3xl" : "p-4")} className={cn(
"flex flex-col gap-4",
isDesktop ? (step == 0 ? "max-w-xl" : "max-w-3xl") : "p-4",
)}
> >
<Header>
<Title>{t("button.addFace")}</Title>
{isDesktop && <Description>{t("description.addFace")}</Description>}
</Header>
<StepIndicator <StepIndicator
steps={STEPS} steps={STEPS}
currentStep={step} currentStep={step}
translationNameSpace="views/faceLibrary" translationNameSpace="views/faceLibrary"
className="mb-4 justify-start"
variant="dots"
/> />
<Header>
<Title>{t("button.addFace")}</Title>
{isDesktop && <Description>{t("description.addFace")}</Description>}
</Header>
{step == 0 && ( {step == 0 && (
<TextEntry <TextEntry
placeholder={t("description.placeholder")} placeholder={t("description.placeholder")}

View File

@ -47,6 +47,7 @@ import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5"; import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { Badge } from "@/components/ui/badge";
type ObjectLifecycleProps = { type ObjectLifecycleProps = {
className?: string; className?: string;
@ -355,6 +356,52 @@ export default function ObjectLifecycle({
return idx === -1 ? 0 : idx; return idx === -1 ? 0 : idx;
}, [eventSequence, timeIndex]); }, [eventSequence, timeIndex]);
// Calculate how far down the blue line should extend based on timeIndex
const calculateLineHeight = () => {
if (!eventSequence || eventSequence.length === 0) return 0;
const currentTime = timeIndex ?? 0;
// Find which events have been passed
let lastPassedIndex = -1;
for (let i = 0; i < eventSequence.length; i++) {
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
lastPassedIndex = i;
} else {
break;
}
}
// No events passed yet
if (lastPassedIndex < 0) return 0;
// All events passed
if (lastPassedIndex >= eventSequence.length - 1) return 100;
// Calculate percentage based on item position, not time
// Each item occupies an equal visual space regardless of time gaps
const itemPercentage = 100 / (eventSequence.length - 1);
// Find progress between current and next event for smooth transition
const currentEvent = eventSequence[lastPassedIndex];
const nextEvent = eventSequence[lastPassedIndex + 1];
const currentTimestamp = currentEvent.timestamp ?? 0;
const nextTimestamp = nextEvent.timestamp ?? 0;
// Calculate interpolation between the two events
const timeBetween = nextTimestamp - currentTimestamp;
const timeElapsed = currentTime - currentTimestamp;
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
// Base position plus interpolated progress to next item
return Math.min(
100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
);
};
const blueLineHeight = calculateLineHeight();
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -569,7 +616,7 @@ export default function ObjectLifecycle({
<div className="mt-4"> <div className="mt-4">
<div <div
className={cn( className={cn(
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500", "rounded-md bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
)} )}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
@ -581,10 +628,12 @@ export default function ObjectLifecycle({
}} }}
role="button" role="button"
> >
<div className={cn("ml-1 rounded-full bg-muted-foreground p-2")}>
{getIconForLabel( {getIconForLabel(
event.label, event.label,
"size-6 text-primary dark:text-white", "size-6 text-primary dark:text-white",
)} )}
</div>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<span>{getTranslatedLabel(event.label)}</span> <span>{getTranslatedLabel(event.label)}</span>
<span className="text-secondary-foreground"> <span className="text-secondary-foreground">
@ -602,7 +651,13 @@ export default function ObjectLifecycle({
{t("detail.noObjectDetailData", { ns: "views/events" })} {t("detail.noObjectDetailData", { ns: "views/events" })}
</div> </div>
) : ( ) : (
<div className="mx-2 mt-4 space-y-2"> <div className="-pb-2 relative mx-2">
<div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
<div
className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }}
/>
<div className="space-y-2">
{eventSequence.map((item, idx) => { {eventSequence.map((item, idx) => {
const isActive = const isActive =
Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5; Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5;
@ -646,9 +701,14 @@ export default function ObjectLifecycle({
: undefined; : undefined;
return ( return (
<div <LifecycleIconRow
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`} key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
role="button" item={item}
isActive={isActive}
formattedEventTimestamp={formattedEventTimestamp}
ratio={ratio}
areaPx={areaPx}
areaPct={areaPct}
onClick={() => { onClick={() => {
setTimeIndex(item.timestamp ?? 0); setTimeIndex(item.timestamp ?? 0);
handleSetBox( handleSetBox(
@ -658,92 +718,13 @@ export default function ObjectLifecycle({
setLifecycleZones(item.data.zones); setLifecycleZones(item.data.zones);
setSelectedZone(""); setSelectedZone("");
}} }}
className={cn( setSelectedZone={setSelectedZone}
"flex cursor-pointer flex-col gap-1 rounded-md p-2 text-sm text-primary-variant", getZoneColor={getZoneColor}
isActive
? "bg-secondary-highlight font-semibold text-primary outline-[1.5px] -outline-offset-[1.1px] outline-primary/40 dark:font-normal"
: "duration-500",
)}
>
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center">
<LifecycleIcon
lifecycleItem={item}
className="size-5"
/> />
</div>
<div className="flex w-full flex-row justify-between">
<div>{getLifecycleItemDescription(item)}</div>
<div className={cn("p-1 text-sm")}>
{formattedEventTimestamp}
</div>
</div>
</div>
<div className="ml-8 mt-1 flex flex-wrap items-center gap-3 text-sm text-secondary-foreground">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">
{t(
"objectLifecycle.lifecycleItemDesc.header.ratio",
)}
</span>
<span className="font-medium text-foreground">
{ratio}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">
{t(
"objectLifecycle.lifecycleItemDesc.header.area",
)}
</span>
{areaPx !== undefined && areaPct !== undefined ? (
<span className="font-medium text-foreground">
px: {areaPx} · %: {areaPct}
</span>
) : (
<span>N/A</span>
)}
</div>
{item.class_type === "entered_zone" && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
{t(
"objectLifecycle.lifecycleItemDesc.header.zones",
)}
</span>
<div className="flex flex-wrap items-center gap-2">
{item.data.zones.map((zone, zidx) => (
<div
key={`${zone}-${zidx}`}
className="flex cursor-pointer items-center gap-1"
onClick={(e) => {
e.stopPropagation();
setSelectedZone(zone);
}}
>
<div
className="size-3 rounded"
style={{
backgroundColor: `rgb(${getZoneColor(zone)})`,
}}
/>
<span className="smart-capitalize">
{zone.replaceAll("_", " ")}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
); );
})} })}
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>
@ -789,3 +770,117 @@ export function LifecycleIcon({
return null; return null;
} }
} }
type LifecycleIconRowProps = {
item: ObjectLifecycleSequence;
isActive?: boolean;
formattedEventTimestamp: string;
ratio: string;
areaPx?: number;
areaPct?: string;
onClick: () => void;
setSelectedZone: (z: string) => void;
getZoneColor: (zoneName: string) => number[] | undefined;
};
function LifecycleIconRow({
item,
isActive,
formattedEventTimestamp,
ratio,
areaPx,
areaPct,
onClick,
setSelectedZone,
getZoneColor,
}: LifecycleIconRowProps) {
const { t } = useTranslation(["views/explore"]);
return (
<div
role="button"
onClick={onClick}
className={cn(
"rounded-md p-2 text-sm text-primary-variant",
isActive && "bg-secondary-highlight font-semibold text-primary",
!isActive && "duration-500",
)}
>
<div className="flex items-center gap-2">
<div className="relative flex size-4 items-center justify-center">
<LuCircle
className={cn(
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
isActive && "fill-selected duration-300",
)}
/>
</div>
<div className="flex w-full flex-row justify-between">
<div className="flex flex-col">
<div>{getLifecycleItemDescription(item)}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary-foreground md:gap-5">
<div className="flex items-center gap-1">
<span className="text-primary-variant">
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
</span>
<span className="font-medium text-primary">{ratio}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-primary-variant">
{t("objectLifecycle.lifecycleItemDesc.header.area")}
</span>
{areaPx !== undefined && areaPct !== undefined ? (
<span className="font-medium text-primary">
{t("information.pixels", { ns: "common", area: areaPx })} ·{" "}
{areaPct}%
</span>
) : (
<span>N/A</span>
)}
</div>
{item.data?.zones && item.data.zones.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{item.data.zones.map((zone, zidx) => {
const color = getZoneColor(zone)?.join(",") ?? "0,0,0";
return (
<Badge
key={`${zone}-${zidx}`}
variant="outline"
className="inline-flex cursor-pointer items-center gap-2"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setSelectedZone(zone);
}}
style={{
borderColor: `rgba(${color}, 0.6)`,
background: `rgba(${color}, 0.08)`,
}}
>
<span
className="size-1 rounded-full"
style={{
display: "inline-block",
width: 10,
height: 10,
backgroundColor: `rgb(${color})`,
}}
/>
<span className="smart-capitalize">
{zone.replaceAll("_", " ")}
</span>
</Badge>
);
})}
</div>
)}
</div>
</div>
<div className={cn("p-1 text-sm")}>{formattedEventTimestamp}</div>
</div>
</div>
</div>
);
}

View File

@ -66,6 +66,7 @@ export default function Events() {
camera: resp.data.camera, camera: resp.data.camera,
startTime, startTime,
severity: resp.data.severity, severity: resp.data.severity,
timelineType: "detail",
}, },
true, true,
); );

View File

@ -858,14 +858,20 @@ function FaceAttemptGroup({
faceNames={faceNames} faceNames={faceNames}
onTrainAttempt={(name) => onTrainAttempt(data, name)} onTrainAttempt={(name) => onTrainAttempt(data, name)}
> >
<AddFaceIcon className="size-7 cursor-pointer p-1 text-gray-200 hover:rounded-full hover:bg-primary-foreground/40" /> <div className="group relative inline-flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<AddFaceIcon className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white" />
</div>
</FaceSelectionDialog> </FaceSelectionDialog>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div className="group relative inline-flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<LuRefreshCw <LuRefreshCw
className="size-7 cursor-pointer p-1 text-gray-200 hover:rounded-full hover:bg-primary-foreground/40" className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white"
onClick={() => onReprocess(data)} onClick={() => onReprocess(data)}
/> />
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("button.reprocessFace")}</TooltipContent> <TooltipContent>{t("button.reprocessFace")}</TooltipContent>
</Tooltip> </Tooltip>

View File

@ -37,6 +37,7 @@ export type RecordingStartingPoint = {
camera: string; camera: string;
startTime: number; startTime: number;
severity: ReviewSeverity; severity: ReviewSeverity;
timelineType?: "timeline" | "events" | "detail";
}; };
export type RecordingPlayerError = "stalled" | "startup"; export type RecordingPlayerError = "stalled" | "startup";

View File

@ -811,7 +811,10 @@ function StateTrainGrid({
image={data.filename} image={data.filename}
onRefresh={onRefresh} onRefresh={onRefresh}
> >
<TbCategoryPlus className="size-7 cursor-pointer p-1 text-gray-200 hover:rounded-full hover:bg-primary-foreground/40" /> <div className="group relative inline-flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<TbCategoryPlus className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white" />
</div>
</ClassificationSelectionDialog> </ClassificationSelectionDialog>
</ClassificationCard> </ClassificationCard>
</div> </div>
@ -958,7 +961,10 @@ function ObjectTrainGrid({
image={data.filename} image={data.filename}
onRefresh={onRefresh} onRefresh={onRefresh}
> >
<TbCategoryPlus className="size-7 cursor-pointer p-1 text-gray-200 hover:rounded-full hover:bg-primary-foreground/40" /> <div className="group relative inline-flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<TbCategoryPlus className="relative z-10 size-5 cursor-pointer text-white/85 hover:text-white" />
</div>
</ClassificationSelectionDialog> </ClassificationSelectionDialog>
</> </>
)} )}

View File

@ -53,8 +53,6 @@ import { cn } from "@/lib/utils";
import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter"; import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
import { GiSoundWaves } from "react-icons/gi"; import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -398,6 +396,7 @@ export default function EventView({
onSelectAllReviews={onSelectAllReviews} onSelectAllReviews={onSelectAllReviews}
setSelectedReviews={setSelectedReviews} setSelectedReviews={setSelectedReviews}
pullLatestData={pullLatestData} pullLatestData={pullLatestData}
onOpenRecording={onOpenRecording}
/> />
)} )}
{severity == "significant_motion" && ( {severity == "significant_motion" && (
@ -441,6 +440,7 @@ type DetectionReviewProps = {
onSelectAllReviews: () => void; onSelectAllReviews: () => void;
setSelectedReviews: (reviews: ReviewSegment[]) => void; setSelectedReviews: (reviews: ReviewSegment[]) => void;
pullLatestData: () => void; pullLatestData: () => void;
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
}; };
function DetectionReview({ function DetectionReview({
contentRef, contentRef,
@ -460,15 +460,12 @@ function DetectionReview({
onSelectAllReviews, onSelectAllReviews,
setSelectedReviews, setSelectedReviews,
pullLatestData, pullLatestData,
onOpenRecording,
}: DetectionReviewProps) { }: DetectionReviewProps) {
const { t } = useTranslation(["views/events"]); const { t } = useTranslation(["views/events"]);
const reviewTimelineRef = useRef<HTMLDivElement>(null); const reviewTimelineRef = useRef<HTMLDivElement>(null);
// detail
const [reviewDetail, setReviewDetail] = useState<ReviewSegment>();
// preview // preview
const [previewTime, setPreviewTime] = useState<number>(); const [previewTime, setPreviewTime] = useState<number>();
@ -688,8 +685,6 @@ function DetectionReview({
return ( return (
<> <>
<ReviewDetailDialog review={reviewDetail} setReview={setReviewDetail} />
<div <div
ref={contentRef} ref={contentRef}
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4" className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"
@ -750,7 +745,12 @@ function DetectionReview({
detail: boolean, detail: boolean,
) => { ) => {
if (detail) { if (detail) {
setReviewDetail(review); onOpenRecording({
camera: review.camera,
startTime: review.start_time - REVIEW_PADDING,
severity: review.severity,
timelineType: "detail",
});
} else { } else {
onSelectReview(review, ctrl); onSelectReview(review, ctrl);
} }

View File

@ -53,6 +53,7 @@ import {
ASPECT_VERTICAL_LAYOUT, ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT, ASPECT_WIDE_LAYOUT,
RecordingSegment, RecordingSegment,
RecordingStartingPoint,
} from "@/types/record"; } from "@/types/record";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -141,9 +142,15 @@ export function RecordingView({
// timeline // timeline
const [recording] = useOverlayState<RecordingStartingPoint>(
"recording",
undefined,
false,
);
const [timelineType, setTimelineType] = useOverlayState<TimelineType>( const [timelineType, setTimelineType] = useOverlayState<TimelineType>(
"timelineType", "timelineType",
"timeline", recording?.timelineType ?? "timeline",
); );
const chunkedTimeRange = useMemo( const chunkedTimeRange = useMemo(

View File

@ -577,7 +577,7 @@ export default function SearchView({
> >
<div <div
className={cn( className={cn(
"aspect-square w-full overflow-hidden rounded-t-lg border", "relative aspect-square w-full overflow-hidden rounded-lg",
)} )}
> >
<SearchThumbnail <SearchThumbnail
@ -634,11 +634,7 @@ export default function SearchView({
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> <div className="absolute bottom-0 left-0 right-0 z-30 bg-gradient-to-t from-black/70 to-transparent p-2">
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
/>
<div className="flex w-full grow items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
<SearchThumbnailFooter <SearchThumbnailFooter
searchResult={value} searchResult={value}
columns={columns} columns={columns}
@ -667,6 +663,10 @@ export default function SearchView({
/> />
</div> </div>
</div> </div>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-30 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
/>
</div>
); );
})} })}
</div> </div>