forked from Github/frigate
Semantic Search Frontend (#12112)
* Add basic search page * Abstract filters to separate components * Make searching functional * Add loading and no results indicators * Implement searching * Combine account and settings menus on mobile * Support using thumbnail for in progress detections * Fetch previews * Move recordings view and open recordings when search is selected * Implement detail pane * Implement saving of description * Implement similarity search * Fix clicking * Add date range picker * Fix * Fix iOS zoom bug * Mobile fixes * Use text area * Fix spacing for drawer * Fix fetching previews incorrectly
This commit is contained in:
444
web/src/components/ui/calendar-range.tsx
Normal file
444
web/src/components/ui/calendar-range.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
/* eslint-disable max-lines */
|
||||
"use client";
|
||||
|
||||
import { type FC, useState, useEffect, useRef } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Calendar } from "./calendar";
|
||||
import { Label } from "./label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./select";
|
||||
import { Switch } from "./switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LuCheck } from "react-icons/lu";
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
/** Click handler for applying the updates from DateRangePicker. */
|
||||
onUpdate?: (values: { range: DateRange; rangeCompare?: DateRange }) => void;
|
||||
/** Initial value for start date */
|
||||
initialDateFrom?: Date | string;
|
||||
/** Initial value for end date */
|
||||
initialDateTo?: Date | string;
|
||||
/** Initial value for start date for compare */
|
||||
initialCompareFrom?: Date | string;
|
||||
/** Initial value for end date for compare */
|
||||
initialCompareTo?: Date | string;
|
||||
/** Alignment of popover */
|
||||
align?: "start" | "center" | "end";
|
||||
/** Option for locale */
|
||||
locale?: string;
|
||||
/** Option for showing compare feature */
|
||||
showCompare?: boolean;
|
||||
}
|
||||
|
||||
const getDateAdjustedForTimezone = (dateInput: Date | string): Date => {
|
||||
if (typeof dateInput === "string") {
|
||||
// Split the date string to get year, month, and day parts
|
||||
const parts = dateInput.split("-").map((part) => parseInt(part, 10));
|
||||
// Create a new Date object using the local timezone
|
||||
// Note: Month is 0-indexed, so subtract 1 from the month part
|
||||
const date = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||
return date;
|
||||
} else {
|
||||
// If dateInput is already a Date object, return it directly
|
||||
return dateInput;
|
||||
}
|
||||
};
|
||||
|
||||
interface DateRange {
|
||||
from: Date;
|
||||
to: Date | undefined;
|
||||
}
|
||||
|
||||
interface Preset {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Define presets
|
||||
const PRESETS: Preset[] = [
|
||||
{ name: "today", label: "Today" },
|
||||
{ name: "yesterday", label: "Yesterday" },
|
||||
{ name: "last7", label: "Last 7 days" },
|
||||
{ name: "last14", label: "Last 14 days" },
|
||||
{ name: "last30", label: "Last 30 days" },
|
||||
{ name: "thisWeek", label: "This Week" },
|
||||
{ name: "lastWeek", label: "Last Week" },
|
||||
{ name: "thisMonth", label: "This Month" },
|
||||
{ name: "lastMonth", label: "Last Month" },
|
||||
];
|
||||
|
||||
/** The DateRangePicker component allows a user to select a range of dates */
|
||||
export const DateRangePicker: FC<DateRangePickerProps> = ({
|
||||
initialDateFrom = new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
initialDateTo,
|
||||
initialCompareFrom,
|
||||
initialCompareTo,
|
||||
onUpdate,
|
||||
showCompare = true,
|
||||
}): JSX.Element => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [range, setRange] = useState<DateRange>({
|
||||
from: getDateAdjustedForTimezone(initialDateFrom),
|
||||
to: initialDateTo
|
||||
? getDateAdjustedForTimezone(initialDateTo)
|
||||
: getDateAdjustedForTimezone(initialDateFrom),
|
||||
});
|
||||
const [rangeCompare, setRangeCompare] = useState<DateRange | undefined>(
|
||||
initialCompareFrom
|
||||
? {
|
||||
from: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)),
|
||||
to: initialCompareTo
|
||||
? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0))
|
||||
: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Refs to store the values of range and rangeCompare when the date picker is opened
|
||||
const openedRangeRef = useRef<DateRange | undefined>();
|
||||
const openedRangeCompareRef = useRef<DateRange | undefined>();
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(
|
||||
typeof window !== "undefined" ? window.innerWidth < 960 : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
setIsSmallScreen(window.innerWidth < 960);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Clean up event listener on unmount
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPresetRange = (presetName: string): DateRange => {
|
||||
const preset = PRESETS.find(({ name }) => name === presetName);
|
||||
if (!preset) throw new Error(`Unknown date range preset: ${presetName}`);
|
||||
const from = new Date();
|
||||
const to = new Date();
|
||||
const first = from.getDate() - from.getDay();
|
||||
|
||||
switch (preset.name) {
|
||||
case "today":
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "yesterday":
|
||||
from.setDate(from.getDate() - 1);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setDate(to.getDate() - 1);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "last7":
|
||||
from.setDate(from.getDate() - 6);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "last14":
|
||||
from.setDate(from.getDate() - 13);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "last30":
|
||||
from.setDate(from.getDate() - 29);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "thisWeek":
|
||||
from.setDate(first);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "lastWeek":
|
||||
from.setDate(from.getDate() - 7 - from.getDay());
|
||||
to.setDate(to.getDate() - to.getDay() - 1);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "thisMonth":
|
||||
from.setDate(1);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case "lastMonth":
|
||||
from.setMonth(from.getMonth() - 1);
|
||||
from.setDate(1);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setDate(0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
const setPreset = (preset: string): void => {
|
||||
const range = getPresetRange(preset);
|
||||
setRange(range);
|
||||
if (rangeCompare) {
|
||||
const rangeCompare = {
|
||||
from: new Date(
|
||||
range.from.getFullYear() - 1,
|
||||
range.from.getMonth(),
|
||||
range.from.getDate(),
|
||||
),
|
||||
to: range.to
|
||||
? new Date(
|
||||
range.to.getFullYear() - 1,
|
||||
range.to.getMonth(),
|
||||
range.to.getDate(),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
setRangeCompare(rangeCompare);
|
||||
}
|
||||
};
|
||||
|
||||
const checkPreset = (): void => {
|
||||
for (const preset of PRESETS) {
|
||||
const presetRange = getPresetRange(preset.name);
|
||||
|
||||
const normalizedRangeFrom = new Date(range.from);
|
||||
normalizedRangeFrom.setHours(0, 0, 0, 0);
|
||||
const normalizedPresetFrom = new Date(
|
||||
presetRange.from.setHours(0, 0, 0, 0),
|
||||
);
|
||||
|
||||
const normalizedRangeTo = new Date(range.to ?? 0);
|
||||
normalizedRangeTo.setHours(0, 0, 0, 0);
|
||||
const normalizedPresetTo = new Date(
|
||||
presetRange.to?.setHours(0, 0, 0, 0) ?? 0,
|
||||
);
|
||||
|
||||
if (
|
||||
normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() &&
|
||||
normalizedRangeTo.getTime() === normalizedPresetTo.getTime()
|
||||
) {
|
||||
setSelectedPreset(preset.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPreset(undefined);
|
||||
};
|
||||
|
||||
const resetValues = (): void => {
|
||||
setRange({
|
||||
from:
|
||||
typeof initialDateFrom === "string"
|
||||
? getDateAdjustedForTimezone(initialDateFrom)
|
||||
: initialDateFrom,
|
||||
to: initialDateTo
|
||||
? typeof initialDateTo === "string"
|
||||
? getDateAdjustedForTimezone(initialDateTo)
|
||||
: initialDateTo
|
||||
: typeof initialDateFrom === "string"
|
||||
? getDateAdjustedForTimezone(initialDateFrom)
|
||||
: initialDateFrom,
|
||||
});
|
||||
setRangeCompare(
|
||||
initialCompareFrom
|
||||
? {
|
||||
from:
|
||||
typeof initialCompareFrom === "string"
|
||||
? getDateAdjustedForTimezone(initialCompareFrom)
|
||||
: initialCompareFrom,
|
||||
to: initialCompareTo
|
||||
? typeof initialCompareTo === "string"
|
||||
? getDateAdjustedForTimezone(initialCompareTo)
|
||||
: initialCompareTo
|
||||
: typeof initialCompareFrom === "string"
|
||||
? getDateAdjustedForTimezone(initialCompareFrom)
|
||||
: initialCompareFrom,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkPreset();
|
||||
}, [range]);
|
||||
|
||||
const PresetButton = ({
|
||||
preset,
|
||||
label,
|
||||
isSelected,
|
||||
}: {
|
||||
preset: string;
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
}): JSX.Element => (
|
||||
<Button
|
||||
className={cn(isSelected && "pointer-events-none text-white")}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPreset(preset);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<span className={cn("pr-2 opacity-0", isSelected && "opacity-70")}>
|
||||
<LuCheck width={18} height={18} />
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
</Button>
|
||||
);
|
||||
|
||||
// Helper function to check if two date ranges are equal
|
||||
const areRangesEqual = (a?: DateRange, b?: DateRange): boolean => {
|
||||
if (!a || !b) return a === b; // If either is undefined, return true if both are undefined
|
||||
return (
|
||||
a.from.getTime() === b.from.getTime() &&
|
||||
(!a.to || !b.to || a.to.getTime() === b.to.getTime())
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
openedRangeRef.current = range;
|
||||
openedRangeCompareRef.current = rangeCompare;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex py-2">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col items-center justify-end gap-2 px-3 pb-4 lg:flex-row lg:items-start lg:pb-0">
|
||||
{showCompare && (
|
||||
<div className="flex items-center space-x-2 py-1 pr-4">
|
||||
<Switch
|
||||
defaultChecked={Boolean(rangeCompare)}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!range.to) {
|
||||
setRange({
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
});
|
||||
}
|
||||
setRangeCompare({
|
||||
from: new Date(
|
||||
range.from.getFullYear(),
|
||||
range.from.getMonth(),
|
||||
range.from.getDate() - 365,
|
||||
),
|
||||
to: range.to
|
||||
? new Date(
|
||||
range.to.getFullYear() - 1,
|
||||
range.to.getMonth(),
|
||||
range.to.getDate(),
|
||||
)
|
||||
: new Date(
|
||||
range.from.getFullYear() - 1,
|
||||
range.from.getMonth(),
|
||||
range.from.getDate(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setRangeCompare(undefined);
|
||||
}
|
||||
}}
|
||||
id="compare-mode"
|
||||
/>
|
||||
<Label htmlFor="compare-mode">Compare</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSmallScreen && (
|
||||
<Select
|
||||
defaultValue={selectedPreset}
|
||||
onValueChange={(value) => {
|
||||
setPreset(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mx-auto mb-2 w-[180px]">
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.name} value={preset.name}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
onSelect={(value: { from?: Date; to?: Date } | undefined) => {
|
||||
if (value?.from != null) {
|
||||
setRange({ from: value.from, to: value?.to });
|
||||
}
|
||||
}}
|
||||
selected={range}
|
||||
numberOfMonths={isSmallScreen ? 1 : 2}
|
||||
defaultMonth={
|
||||
new Date(
|
||||
new Date().setMonth(
|
||||
new Date().getMonth() - (isSmallScreen ? 0 : 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
<div className="flex flex-col items-end gap-1 pb-6 pl-6 pr-2">
|
||||
<div className="flex w-full flex-col items-end gap-1 pb-6 pl-6 pr-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<PresetButton
|
||||
key={preset.name}
|
||||
preset={preset.name}
|
||||
label={preset.label}
|
||||
isSelected={selectedPreset === preset.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 py-2 pr-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
resetValues();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (
|
||||
!areRangesEqual(range, openedRangeRef.current) ||
|
||||
!areRangesEqual(rangeCompare, openedRangeCompareRef.current)
|
||||
) {
|
||||
onUpdate?.({ range, rangeCompare });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
web/src/components/ui/textarea.tsx
Normal file
24
web/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
Reference in New Issue
Block a user