mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Reviewed buttons (#10271)
* mark items as reviewed when they are opened * Update api to use json and add button to mark all as reviewed * fix api so last24 hours has its own review summary * fix sidebar spacing * formatting * Bug fixes * Make motion activity respect filters
This commit is contained in:
		
							parent
							
								
									b5edcd2fae
								
							
						
					
					
						commit
						68ed18d3f4
					
				@ -76,11 +76,112 @@ def review():
 | 
			
		||||
def review_summary():
 | 
			
		||||
    tz_name = request.args.get("timezone", default="utc", type=str)
 | 
			
		||||
    hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
 | 
			
		||||
    day_ago = (datetime.now() - timedelta(hours=24)).timestamp()
 | 
			
		||||
    month_ago = (datetime.now() - timedelta(days=30)).timestamp()
 | 
			
		||||
 | 
			
		||||
    cameras = request.args.get("cameras", "all")
 | 
			
		||||
    labels = request.args.get("labels", "all")
 | 
			
		||||
 | 
			
		||||
    clauses = [(ReviewSegment.start_time > day_ago)]
 | 
			
		||||
 | 
			
		||||
    if cameras != "all":
 | 
			
		||||
        camera_list = cameras.split(",")
 | 
			
		||||
        clauses.append((ReviewSegment.camera << camera_list))
 | 
			
		||||
 | 
			
		||||
    if labels != "all":
 | 
			
		||||
        # use matching so segments with multiple labels
 | 
			
		||||
        # still match on a search where any label matches
 | 
			
		||||
        label_clauses = []
 | 
			
		||||
        filtered_labels = labels.split(",")
 | 
			
		||||
 | 
			
		||||
        for label in filtered_labels:
 | 
			
		||||
            label_clauses.append(
 | 
			
		||||
                (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        label_clause = reduce(operator.or_, label_clauses)
 | 
			
		||||
        clauses.append((label_clause))
 | 
			
		||||
 | 
			
		||||
    last_24 = (
 | 
			
		||||
        ReviewSegment.select(
 | 
			
		||||
            fn.SUM(
 | 
			
		||||
                Case(
 | 
			
		||||
                    None,
 | 
			
		||||
                    [
 | 
			
		||||
                        (
 | 
			
		||||
                            (ReviewSegment.severity == "alert"),
 | 
			
		||||
                            ReviewSegment.has_been_reviewed,
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                    0,
 | 
			
		||||
                )
 | 
			
		||||
            ).alias("reviewed_alert"),
 | 
			
		||||
            fn.SUM(
 | 
			
		||||
                Case(
 | 
			
		||||
                    None,
 | 
			
		||||
                    [
 | 
			
		||||
                        (
 | 
			
		||||
                            (ReviewSegment.severity == "detection"),
 | 
			
		||||
                            ReviewSegment.has_been_reviewed,
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                    0,
 | 
			
		||||
                )
 | 
			
		||||
            ).alias("reviewed_detection"),
 | 
			
		||||
            fn.SUM(
 | 
			
		||||
                Case(
 | 
			
		||||
                    None,
 | 
			
		||||
                    [
 | 
			
		||||
                        (
 | 
			
		||||
                            (ReviewSegment.severity == "significant_motion"),
 | 
			
		||||
                            ReviewSegment.has_been_reviewed,
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                    0,
 | 
			
		||||
                )
 | 
			
		||||
            ).alias("reviewed_motion"),
 | 
			
		||||
            fn.SUM(
 | 
			
		||||
                Case(
 | 
			
		||||
                    None,
 | 
			
		||||
                    [
 | 
			
		||||
                        (
 | 
			
		||||
                            (ReviewSegment.severity == "alert"),
 | 
			
		||||
                            1,
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                    0,
 | 
			
		||||
                )
 | 
			
		||||
            ).alias("total_alert"),
 | 
			
		||||
            fn.SUM(
 | 
			
		||||
                Case(
 | 
			
		||||
                    None,
 | 
			
		||||
                    [
 | 
			
		||||
                        (
 | 
			
		||||
                            (ReviewSegment.severity == "detection"),
 | 
			
		||||
                            1,
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                    0,
 | 
			
		||||
                )
 | 
			
		||||
            ).alias("total_detection"),
 | 
			
		||||
            fn.SUM(
 | 
			
		||||
                Case(
 | 
			
		||||
                    None,
 | 
			
		||||
                    [
 | 
			
		||||
                        (
 | 
			
		||||
                            (ReviewSegment.severity == "significant_motion"),
 | 
			
		||||
                            1,
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                    0,
 | 
			
		||||
                )
 | 
			
		||||
            ).alias("total_motion"),
 | 
			
		||||
        )
 | 
			
		||||
        .where(reduce(operator.and_, clauses))
 | 
			
		||||
        .dicts()
 | 
			
		||||
        .get()
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    clauses = [(ReviewSegment.start_time > month_ago)]
 | 
			
		||||
 | 
			
		||||
    if cameras != "all":
 | 
			
		||||
@ -101,7 +202,7 @@ def review_summary():
 | 
			
		||||
        label_clause = reduce(operator.or_, label_clauses)
 | 
			
		||||
        clauses.append((label_clause))
 | 
			
		||||
 | 
			
		||||
    groups = (
 | 
			
		||||
    last_month = (
 | 
			
		||||
        ReviewSegment.select(
 | 
			
		||||
            fn.strftime(
 | 
			
		||||
                "%Y-%m-%d",
 | 
			
		||||
@ -192,29 +293,20 @@ def review_summary():
 | 
			
		||||
        .order_by(ReviewSegment.start_time.desc())
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return jsonify([e for e in groups.dicts().iterator()])
 | 
			
		||||
    data = {
 | 
			
		||||
        "last24Hours": last_24,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for e in last_month.dicts().iterator():
 | 
			
		||||
        data[e["day"]] = e
 | 
			
		||||
 | 
			
		||||
    return jsonify(data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ReviewBp.route("/review/<id>/viewed", methods=("POST",))
 | 
			
		||||
def set_reviewed(id):
 | 
			
		||||
    try:
 | 
			
		||||
        review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
 | 
			
		||||
    except DoesNotExist:
 | 
			
		||||
        return make_response(
 | 
			
		||||
            jsonify({"success": False, "message": "Review " + id + " not found"}), 404
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    review.has_been_reviewed = True
 | 
			
		||||
    review.save()
 | 
			
		||||
 | 
			
		||||
    return make_response(
 | 
			
		||||
        jsonify({"success": True, "message": "Reviewed " + id + " viewed"}), 200
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ReviewBp.route("/reviews/<ids>/viewed", methods=("POST",))
 | 
			
		||||
def set_multiple_reviewed(ids: str):
 | 
			
		||||
    list_of_ids = ids.split(",")
 | 
			
		||||
@ReviewBp.route("/reviews/viewed", methods=("POST",))
 | 
			
		||||
def set_multiple_reviewed():
 | 
			
		||||
    json: dict[str, any] = request.get_json(silent=True) or {}
 | 
			
		||||
    list_of_ids = json.get("ids", "")
 | 
			
		||||
 | 
			
		||||
    if not list_of_ids or len(list_of_ids) == 0:
 | 
			
		||||
        return make_response(
 | 
			
		||||
@ -264,13 +356,17 @@ def delete_reviews(ids: str):
 | 
			
		||||
@ReviewBp.route("/review/activity")
 | 
			
		||||
def review_activity():
 | 
			
		||||
    """Get motion and audio activity."""
 | 
			
		||||
    cameras = request.args.get("cameras", "all")
 | 
			
		||||
    before = request.args.get("before", type=float, default=datetime.now().timestamp())
 | 
			
		||||
    after = request.args.get(
 | 
			
		||||
        "after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # get scale in seconds
 | 
			
		||||
    scale = request.args.get("scale", type=int, default=30)
 | 
			
		||||
    clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
 | 
			
		||||
 | 
			
		||||
    if cameras != "all":
 | 
			
		||||
        camera_list = cameras.split(",")
 | 
			
		||||
        clauses.append((Recordings.camera << camera_list))
 | 
			
		||||
 | 
			
		||||
    all_recordings: list[Recordings] = (
 | 
			
		||||
        Recordings.select(
 | 
			
		||||
@ -280,7 +376,7 @@ def review_activity():
 | 
			
		||||
            Recordings.motion,
 | 
			
		||||
            Recordings.dBFS,
 | 
			
		||||
        )
 | 
			
		||||
        .where((Recordings.start_time > after) & (Recordings.end_time < before))
 | 
			
		||||
        .where(reduce(operator.and_, clauses))
 | 
			
		||||
        .order_by(Recordings.start_time.asc())
 | 
			
		||||
        .iterator()
 | 
			
		||||
    )
 | 
			
		||||
@ -298,6 +394,9 @@ def review_activity():
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # get scale in seconds
 | 
			
		||||
    scale = request.args.get("scale", type=int, default=30)
 | 
			
		||||
 | 
			
		||||
    # resample data using pandas to get activity on scaled basis
 | 
			
		||||
    df = pd.DataFrame(data, columns=["start_time", "motion", "audio"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,7 @@ export default function ReviewActionGroup({
 | 
			
		||||
 | 
			
		||||
  const onMarkAsReviewed = useCallback(async () => {
 | 
			
		||||
    const idList = selectedReviews.join(",");
 | 
			
		||||
    await axios.post(`reviews/${idList}/viewed`);
 | 
			
		||||
    await axios.post(`reviews/viewed`, { ids: idList });
 | 
			
		||||
    setSelectedReviews([]);
 | 
			
		||||
    pullLatestData();
 | 
			
		||||
  }, [selectedReviews, setSelectedReviews, pullLatestData]);
 | 
			
		||||
 | 
			
		||||
@ -13,20 +13,23 @@ function Sidebar() {
 | 
			
		||||
      <span tabIndex={0} className="sr-only" />
 | 
			
		||||
      <div className="w-full flex flex-col gap-0 items-center">
 | 
			
		||||
        <Logo className="w-8 h-8 mb-6" />
 | 
			
		||||
        {navbarLinks.map((item) => (
 | 
			
		||||
        {navbarLinks.map((item) => {
 | 
			
		||||
          const showCameraGroups =
 | 
			
		||||
            item.id == 1 && item.url == location.pathname;
 | 
			
		||||
 | 
			
		||||
          return (
 | 
			
		||||
            <div key={item.id}>
 | 
			
		||||
              <NavItem
 | 
			
		||||
              className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-4"}`}
 | 
			
		||||
                className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
 | 
			
		||||
                Icon={item.icon}
 | 
			
		||||
                title={item.title}
 | 
			
		||||
                url={item.url}
 | 
			
		||||
                dev={item.dev}
 | 
			
		||||
              />
 | 
			
		||||
            {item.id == 1 && item.url == location.pathname && (
 | 
			
		||||
              <CameraGroupSelector className="mb-4" />
 | 
			
		||||
            )}
 | 
			
		||||
              {showCameraGroups && <CameraGroupSelector className="mb-4" />}
 | 
			
		||||
            </div>
 | 
			
		||||
        ))}
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </div>
 | 
			
		||||
      <SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
 | 
			
		||||
    </aside>
 | 
			
		||||
 | 
			
		||||
@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
 | 
			
		||||
  timeRange: { start: number; end: number };
 | 
			
		||||
  cameraPreviews: Preview[];
 | 
			
		||||
  previewOnly?: boolean;
 | 
			
		||||
  onControllerReady?: (controller: DynamicVideoController) => void;
 | 
			
		||||
  onControllerReady: (controller: DynamicVideoController) => void;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
};
 | 
			
		||||
export default function DynamicVideoPlayer({
 | 
			
		||||
@ -86,14 +86,17 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
  }, [camera, config, previewOnly]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!controller) {
 | 
			
		||||
    if (!playerRef.current && !previewRef.current) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (onControllerReady) {
 | 
			
		||||
    if (controller) {
 | 
			
		||||
      onControllerReady(controller);
 | 
			
		||||
    }
 | 
			
		||||
  }, [controller, onControllerReady]);
 | 
			
		||||
 | 
			
		||||
    // we only want to fire once when players are ready
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [playerRef, previewRef]);
 | 
			
		||||
 | 
			
		||||
  const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
 | 
			
		||||
 | 
			
		||||
@ -277,10 +280,6 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
              player.on("ended", () =>
 | 
			
		||||
                controller.fireClipChangeEvent("forward"),
 | 
			
		||||
              );
 | 
			
		||||
 | 
			
		||||
              if (onControllerReady) {
 | 
			
		||||
                onControllerReady(controller);
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
            onDispose={() => {
 | 
			
		||||
              playerRef.current = undefined;
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ const buttonVariants = cva(
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
 | 
			
		||||
        select: "bg-select text-white hover:bg-select/90",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
 | 
			
		||||
        outline:
 | 
			
		||||
 | 
			
		||||
@ -115,9 +115,7 @@ export default function Events() {
 | 
			
		||||
 | 
			
		||||
  // review summary
 | 
			
		||||
 | 
			
		||||
  const { data: reviewSummary, mutate: updateSummary } = useSWR<
 | 
			
		||||
    ReviewSummary[]
 | 
			
		||||
  >([
 | 
			
		||||
  const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>([
 | 
			
		||||
    "review/summary",
 | 
			
		||||
    {
 | 
			
		||||
      timezone: timezone,
 | 
			
		||||
@ -164,7 +162,7 @@ export default function Events() {
 | 
			
		||||
 | 
			
		||||
  const markItemAsReviewed = useCallback(
 | 
			
		||||
    async (review: ReviewSegment) => {
 | 
			
		||||
      const resp = await axios.post(`review/${review.id}/viewed`);
 | 
			
		||||
      const resp = await axios.post(`reviews/viewed`, { ids: [review.id] });
 | 
			
		||||
 | 
			
		||||
      if (resp.status == 200) {
 | 
			
		||||
        updateSegments(
 | 
			
		||||
@ -197,23 +195,30 @@ export default function Events() {
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        updateSummary(
 | 
			
		||||
          (data: ReviewSummary[] | undefined) => {
 | 
			
		||||
          (data: ReviewSummary | undefined) => {
 | 
			
		||||
            if (!data) {
 | 
			
		||||
              return data;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const day = new Date(review.start_time * 1000);
 | 
			
		||||
            const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
 | 
			
		||||
            const index = data.findIndex((summary) => summary.day == key);
 | 
			
		||||
            const today = new Date();
 | 
			
		||||
            today.setHours(0, 0, 0, 0);
 | 
			
		||||
 | 
			
		||||
            if (index == -1) {
 | 
			
		||||
            let key;
 | 
			
		||||
            if (day.getTime() > today.getTime()) {
 | 
			
		||||
              key = "last24Hours";
 | 
			
		||||
            } else {
 | 
			
		||||
              key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!Object.keys(data).includes(key)) {
 | 
			
		||||
              return data;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const item = data[index];
 | 
			
		||||
            return [
 | 
			
		||||
              ...data.slice(0, index),
 | 
			
		||||
              {
 | 
			
		||||
            const item = data[key];
 | 
			
		||||
            return {
 | 
			
		||||
              ...data,
 | 
			
		||||
              [key]: {
 | 
			
		||||
                ...item,
 | 
			
		||||
                reviewed_alert:
 | 
			
		||||
                  review.severity == "alert"
 | 
			
		||||
@ -228,8 +233,7 @@ export default function Events() {
 | 
			
		||||
                    ? item.reviewed_motion + 1
 | 
			
		||||
                    : item.reviewed_motion,
 | 
			
		||||
              },
 | 
			
		||||
              ...data.slice(index + 1),
 | 
			
		||||
            ];
 | 
			
		||||
            };
 | 
			
		||||
          },
 | 
			
		||||
          { revalidate: false, populateCache: true },
 | 
			
		||||
        );
 | 
			
		||||
@ -279,6 +283,11 @@ export default function Events() {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // mark item as reviewed since it has been opened
 | 
			
		||||
    if (!selectedReview?.has_been_reviewed) {
 | 
			
		||||
      markItemAsReviewed(selectedReview);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      camera: selectedReview.camera,
 | 
			
		||||
      severity: selectedReview.severity,
 | 
			
		||||
 | 
			
		||||
@ -28,7 +28,7 @@ export type ReviewFilter = {
 | 
			
		||||
  showReviewed?: 0 | 1;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ReviewSummary = {
 | 
			
		||||
type ReviewSummaryDay = {
 | 
			
		||||
  day: string;
 | 
			
		||||
  reviewed_alert: number;
 | 
			
		||||
  reviewed_detection: number;
 | 
			
		||||
@ -38,6 +38,10 @@ export type ReviewSummary = {
 | 
			
		||||
  total_motion: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ReviewSummary = {
 | 
			
		||||
  [day: string]: ReviewSummaryDay;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type MotionData = {
 | 
			
		||||
  start_time: number;
 | 
			
		||||
  motion: number;
 | 
			
		||||
 | 
			
		||||
@ -35,10 +35,11 @@ import { LuFolderCheck } from "react-icons/lu";
 | 
			
		||||
import { MdCircle } from "react-icons/md";
 | 
			
		||||
import useSWR from "swr";
 | 
			
		||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
type EventViewProps = {
 | 
			
		||||
  reviewPages?: ReviewSegment[][];
 | 
			
		||||
  reviewSummary?: ReviewSummary[];
 | 
			
		||||
  reviewSummary?: ReviewSummary;
 | 
			
		||||
  relevantPreviews?: Preview[];
 | 
			
		||||
  timeRange: { before: number; after: number };
 | 
			
		||||
  reachedEnd: boolean;
 | 
			
		||||
@ -74,17 +75,17 @@ export default function EventView({
 | 
			
		||||
  // review counts
 | 
			
		||||
 | 
			
		||||
  const reviewCounts = useMemo(() => {
 | 
			
		||||
    if (!reviewSummary || reviewSummary.length == 0) {
 | 
			
		||||
    if (!reviewSummary) {
 | 
			
		||||
      return { alert: 0, detection: 0, significant_motion: 0 };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let summary;
 | 
			
		||||
    if (filter?.before == undefined) {
 | 
			
		||||
      summary = reviewSummary[0];
 | 
			
		||||
      summary = reviewSummary["last24Hours"];
 | 
			
		||||
    } else {
 | 
			
		||||
      const day = new Date(filter.before * 1000);
 | 
			
		||||
      const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
 | 
			
		||||
      summary = reviewSummary.find((check) => check.day == key);
 | 
			
		||||
      summary = reviewSummary[key];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!summary) {
 | 
			
		||||
@ -211,9 +212,11 @@ export default function EventView({
 | 
			
		||||
        <ToggleGroup
 | 
			
		||||
          className="*:px-3 *:py-4 *:rounded-2xl"
 | 
			
		||||
          type="single"
 | 
			
		||||
          defaultValue="alert"
 | 
			
		||||
          size="sm"
 | 
			
		||||
          onValueChange={(value: ReviewSeverity) => setSeverity(value)}
 | 
			
		||||
          value={severity}
 | 
			
		||||
          onValueChange={(value: ReviewSeverity) =>
 | 
			
		||||
            value ? setSeverity(value) : null
 | 
			
		||||
          } // don't allow the severity to be unselected
 | 
			
		||||
        >
 | 
			
		||||
          <ToggleGroupItem
 | 
			
		||||
            className={`${severity == "alert" ? "" : "text-gray-500"}`}
 | 
			
		||||
@ -241,9 +244,7 @@ export default function EventView({
 | 
			
		||||
            aria-label="Select motion"
 | 
			
		||||
          >
 | 
			
		||||
            <MdCircle className="size-2 md:mr-[10px] text-severity_motion" />
 | 
			
		||||
            <div className="hidden md:block">
 | 
			
		||||
              Motion ∙ {reviewCounts.significant_motion}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="hidden md:block">Motion</div>
 | 
			
		||||
          </ToggleGroupItem>
 | 
			
		||||
        </ToggleGroup>
 | 
			
		||||
 | 
			
		||||
@ -303,6 +304,7 @@ type DetectionReviewProps = {
 | 
			
		||||
    detection: ReviewSegment[];
 | 
			
		||||
    significant_motion: ReviewSegment[];
 | 
			
		||||
  };
 | 
			
		||||
  itemsToReview?: number;
 | 
			
		||||
  relevantPreviews?: Preview[];
 | 
			
		||||
  pagingObserver: MutableRefObject<IntersectionObserver | null>;
 | 
			
		||||
  selectedReviews: string[];
 | 
			
		||||
@ -320,6 +322,7 @@ function DetectionReview({
 | 
			
		||||
  contentRef,
 | 
			
		||||
  currentItems,
 | 
			
		||||
  reviewItems,
 | 
			
		||||
  itemsToReview,
 | 
			
		||||
  relevantPreviews,
 | 
			
		||||
  pagingObserver,
 | 
			
		||||
  selectedReviews,
 | 
			
		||||
@ -359,6 +362,17 @@ function DetectionReview({
 | 
			
		||||
    [isValidating, pagingObserver, reachedEnd, loadNextPage],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const markAllReviewed = useCallback(async () => {
 | 
			
		||||
    if (!currentItems) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await axios.post(`reviews/viewed`, {
 | 
			
		||||
      ids: currentItems?.map((seg) => seg.id),
 | 
			
		||||
    });
 | 
			
		||||
    pullLatestData();
 | 
			
		||||
  }, [currentItems, pullLatestData]);
 | 
			
		||||
 | 
			
		||||
  // timeline interaction
 | 
			
		||||
 | 
			
		||||
  const { alignStartDateToTimeline } = useEventUtils(
 | 
			
		||||
@ -453,7 +467,7 @@ function DetectionReview({
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {!isValidating && currentItems == null && (
 | 
			
		||||
        {(itemsToReview == 0 || (currentItems == null && !isValidating)) && (
 | 
			
		||||
          <div className="size-full flex flex-col justify-center items-center">
 | 
			
		||||
            <LuFolderCheck className="size-16" />
 | 
			
		||||
            There are no {severity.replace(/_/g, " ")} items to review
 | 
			
		||||
@ -489,13 +503,27 @@ function DetectionReview({
 | 
			
		||||
                      onClick={onSelectReview}
 | 
			
		||||
                    />
 | 
			
		||||
                  </div>
 | 
			
		||||
                  {lastRow && !reachedEnd && <ActivityIndicator />}
 | 
			
		||||
                </div>
 | 
			
		||||
              );
 | 
			
		||||
            })
 | 
			
		||||
          ) : severity != "alert" ? (
 | 
			
		||||
          ) : itemsToReview != 0 ? (
 | 
			
		||||
            <div ref={lastReviewRef} />
 | 
			
		||||
          ) : null}
 | 
			
		||||
          {currentItems && (
 | 
			
		||||
            <div className="col-span-full flex justify-center items-center">
 | 
			
		||||
              {reachedEnd ? (
 | 
			
		||||
                <Button
 | 
			
		||||
                  className="text-white"
 | 
			
		||||
                  variant="select"
 | 
			
		||||
                  onClick={markAllReviewed}
 | 
			
		||||
                >
 | 
			
		||||
                  Mark all items as reviewed
 | 
			
		||||
                </Button>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <ActivityIndicator />
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
 | 
			
		||||
@ -574,6 +602,7 @@ function MotionReview({
 | 
			
		||||
      before: timeRange.before,
 | 
			
		||||
      after: timeRange.after,
 | 
			
		||||
      scale: segmentDuration / 2,
 | 
			
		||||
      cameras: filter?.cameras?.join(",") ?? null,
 | 
			
		||||
    },
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user