mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-08 20:25:26 +03:00
Get cameras filter working
This commit is contained in:
parent
4a7c159a44
commit
01ded3fcc5
@ -1055,9 +1055,9 @@ def event_snapshot(id):
|
||||
else:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if download:
|
||||
response.headers[
|
||||
"Content-Disposition"
|
||||
] = f"attachment; filename=snapshot-{id}.jpg"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f"attachment; filename=snapshot-{id}.jpg"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@ -1244,9 +1244,9 @@ def event_clip(id):
|
||||
if download:
|
||||
response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name
|
||||
response.headers["Content-Length"] = os.path.getsize(clip_path)
|
||||
response.headers[
|
||||
"X-Accel-Redirect"
|
||||
] = f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
response.headers["X-Accel-Redirect"] = (
|
||||
f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@ -1949,9 +1949,9 @@ def get_recordings_storage_usage():
|
||||
|
||||
total_mb = recording_stats["total"]
|
||||
|
||||
camera_usages: dict[
|
||||
str, dict
|
||||
] = current_app.storage_maintainer.calculate_camera_usages()
|
||||
camera_usages: dict[str, dict] = (
|
||||
current_app.storage_maintainer.calculate_camera_usages()
|
||||
)
|
||||
|
||||
for camera_name in camera_usages.keys():
|
||||
if camera_usages.get(camera_name, {}).get("usage"):
|
||||
@ -2139,9 +2139,9 @@ def recording_clip(camera_name, start_ts, end_ts):
|
||||
if download:
|
||||
response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name
|
||||
response.headers["Content-Length"] = os.path.getsize(path)
|
||||
response.headers[
|
||||
"X-Accel-Redirect"
|
||||
] = f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
response.headers["X-Accel-Redirect"] = (
|
||||
f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@ -2395,7 +2395,7 @@ def vod_event(id):
|
||||
|
||||
@bp.route("/review")
|
||||
def review():
|
||||
camera = request.args.get("camera", "all")
|
||||
cameras = request.args.get("cameras", "all")
|
||||
limit = request.args.get("limit", 100)
|
||||
severity = request.args.get("severity", None)
|
||||
|
||||
@ -2406,8 +2406,9 @@ def review():
|
||||
|
||||
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
|
||||
|
||||
if camera != "all":
|
||||
clauses.append((ReviewSegment.camera == camera))
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if severity:
|
||||
clauses.append((ReviewSegment.severity == severity))
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { LuCheck, LuFilter } from "react-icons/lu";
|
||||
import { LuCalendar, LuCheck, LuFilter, LuVideo } from "react-icons/lu";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import useSWR from "swr";
|
||||
@ -14,71 +14,49 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Calendar } from "../ui/calendar";
|
||||
import { ReviewFilter } from "@/types/review";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
|
||||
type HistoryFilterPopoverProps = {
|
||||
// @ts-ignore
|
||||
filter: HistoryFilter | undefined;
|
||||
// @ts-ignore
|
||||
onUpdateFilter: (filter: HistoryFilter) => void;
|
||||
type ReviewFilterGroupProps = {
|
||||
filter?: ReviewFilter;
|
||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||
};
|
||||
|
||||
export default function HistoryFilterPopover({
|
||||
export default function ReviewFilterGroup({
|
||||
filter,
|
||||
onUpdateFilter,
|
||||
}: HistoryFilterPopoverProps) {
|
||||
}: ReviewFilterGroupProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
||||
const future = new Date();
|
||||
future.setFullYear(2032);
|
||||
return { from: tomorrow, to: future };
|
||||
}, []);
|
||||
|
||||
const { data: allLabels } = useSWR<string[]>(["labels"], {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: allSubLabels } = useSWR<string[]>(
|
||||
["sub_labels", { split_joined: 1 }],
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
const filterValues = useMemo(
|
||||
() => ({
|
||||
cameras: Object.keys(config?.cameras || {}),
|
||||
labels: Object.values(allLabels || {}),
|
||||
}),
|
||||
[config, allLabels, allSubLabels]
|
||||
[config, allLabels]
|
||||
);
|
||||
const [selectedFilters, setSelectedFilters] = useState({
|
||||
cameras: filter == undefined ? ["all"] : filter.cameras,
|
||||
labels: filter == undefined ? ["all"] : filter.labels,
|
||||
before: filter?.before,
|
||||
after: filter?.after,
|
||||
detailLevel: filter?.detailLevel ?? "normal",
|
||||
});
|
||||
const dateRange = useMemo(() => {
|
||||
return selectedFilters?.before == undefined ||
|
||||
selectedFilters?.after == undefined
|
||||
? undefined
|
||||
: {
|
||||
from: new Date(selectedFilters.after * 1000),
|
||||
to: new Date(selectedFilters.before * 1000),
|
||||
};
|
||||
}, [selectedFilters]);
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
return {
|
||||
cameras:
|
||||
JSON.stringify(selectedFilters.cameras) == JSON.stringify(["all"]),
|
||||
labels: JSON.stringify(selectedFilters.labels) == JSON.stringify(["all"]),
|
||||
};
|
||||
}, [selectedFilters]);
|
||||
|
||||
return (
|
||||
<div className="mr-2">
|
||||
<CamerasFilterButton
|
||||
allCameras={filterValues.cameras}
|
||||
selectedCameras={filter?.cameras}
|
||||
updateCameraFilter={(newCameras) => {
|
||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||
}}
|
||||
/>
|
||||
<CalendarFilterButton before={filter?.before} after={filter?.after} />
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuFilter className=" mr-[10px]" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
/*return (
|
||||
<Popover open={open} onOpenChange={(open) => setOpen(open)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button>
|
||||
@ -88,65 +66,7 @@ export default function HistoryFilterPopover({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-screen sm:w-[340px]">
|
||||
<div className="flex justify-around">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="capitalize" variant="outline">
|
||||
{allItems.cameras
|
||||
? "All Cameras"
|
||||
: `${selectedFilters.cameras.length} Cameras`}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Filter Cameras</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<FilterCheckBox
|
||||
isChecked={allItems.cameras}
|
||||
label="All Cameras"
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
cameras: ["all"],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
{filterValues.cameras.map((item) => (
|
||||
<FilterCheckBox
|
||||
key={item}
|
||||
isChecked={selectedFilters.cameras.includes(item)}
|
||||
label={item.replaceAll("_", " ")}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const selectedCameras = allItems.cameras
|
||||
? []
|
||||
: [...selectedFilters.cameras];
|
||||
selectedCameras.push(item);
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
cameras: selectedCameras,
|
||||
});
|
||||
} else {
|
||||
const selectedCameraList = [...selectedFilters.cameras];
|
||||
|
||||
// can not deselect the last item
|
||||
if (selectedCameraList.length > 1) {
|
||||
selectedCameraList.splice(
|
||||
selectedCameraList.indexOf(item),
|
||||
1
|
||||
);
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
cameras: selectedCameraList,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="capitalize" variant="outline">
|
||||
@ -216,9 +136,7 @@ export default function HistoryFilterPopover({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>
|
||||
Detail Level
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>Detail Level</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedFilters.detailLevel}
|
||||
@ -276,6 +194,120 @@ export default function HistoryFilterPopover({
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);*/
|
||||
}
|
||||
|
||||
type CameraFilterButtonProps = {
|
||||
allCameras: string[];
|
||||
selectedCameras: string[] | undefined;
|
||||
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||
};
|
||||
function CamerasFilterButton({
|
||||
allCameras,
|
||||
selectedCameras,
|
||||
updateCameraFilter,
|
||||
}: CameraFilterButtonProps) {
|
||||
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
|
||||
selectedCameras
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
updateCameraFilter(currentCameras);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="mx-1 capitalize" variant="secondary">
|
||||
<LuVideo className=" mr-[10px]" />
|
||||
{selectedCameras == undefined
|
||||
? "All Cameras"
|
||||
: `${selectedCameras.length} Cameras`}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Filter Cameras</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<FilterCheckBox
|
||||
isChecked={currentCameras == undefined}
|
||||
label="All Cameras"
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentCameras(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
{allCameras.map((item) => (
|
||||
<FilterCheckBox
|
||||
key={item}
|
||||
isChecked={currentCameras?.includes(item) ?? false}
|
||||
label={item.replaceAll("_", " ")}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updatedCameras = currentCameras
|
||||
? [...currentCameras]
|
||||
: [];
|
||||
|
||||
updatedCameras.push(item);
|
||||
setCurrentCameras(updatedCameras);
|
||||
} else {
|
||||
const updatedCameras = currentCameras
|
||||
? [...currentCameras]
|
||||
: [];
|
||||
|
||||
// can not deselect the last item
|
||||
if (updatedCameras.length > 1) {
|
||||
updatedCameras.splice(updatedCameras.indexOf(item), 1);
|
||||
setCurrentCameras(updatedCameras);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarFilterButtonProps = {
|
||||
before: number | undefined;
|
||||
after: number | undefined;
|
||||
};
|
||||
function CalendarFilterButton({ before, after }: CalendarFilterButtonProps) {
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
||||
const future = new Date();
|
||||
future.setFullYear(tomorrow.getFullYear() + 10);
|
||||
return { from: tomorrow, to: future };
|
||||
}, []);
|
||||
const dateRange = useMemo(() => {
|
||||
return before == undefined || after == undefined
|
||||
? undefined
|
||||
: {
|
||||
from: new Date(after * 1000),
|
||||
to: new Date(before * 1000),
|
||||
};
|
||||
}, [before, after]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuCalendar className=" mr-[10px]" />
|
||||
{formatUnixTimestampToDateTime(Date.now() / 1000, {
|
||||
strftime_fmt: "%b %-d",
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Calendar mode="single" disabled={disabledDates} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type useApiFilterReturn<F extends FilterType> = [
|
||||
filter: F | undefined,
|
||||
filter: F,
|
||||
setFilter: (filter: F) => void,
|
||||
searchParams:
|
||||
| {
|
||||
[key: string]: any;
|
||||
}
|
||||
| undefined,
|
||||
searchParams: {
|
||||
[key: string]: any;
|
||||
},
|
||||
];
|
||||
|
||||
export default function useApiFilter<
|
||||
@ -16,7 +14,7 @@ export default function useApiFilter<
|
||||
const [filter, setFilter] = useState<F | undefined>(undefined);
|
||||
const searchParams = useMemo(() => {
|
||||
if (filter == undefined) {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
|
||||
const search: { [key: string]: string } = {};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import useApiFilter from "@/hooks/use-api-filter";
|
||||
import useOverlayState from "@/hooks/use-overlay-state";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { ReviewFilter, ReviewSegment } from "@/types/review";
|
||||
import DesktopEventView from "@/views/events/DesktopEventView";
|
||||
import DesktopRecordingView from "@/views/events/DesktopRecordingView";
|
||||
import MobileEventView from "@/views/events/MobileEventView";
|
||||
@ -15,6 +16,11 @@ export default function Events() {
|
||||
// recordings viewer
|
||||
const [selectedReviewId, setSelectedReviewId] = useOverlayState("review");
|
||||
|
||||
// review filter
|
||||
|
||||
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
||||
useApiFilter<ReviewFilter>();
|
||||
|
||||
// review paging
|
||||
|
||||
const timeRange = useMemo(() => {
|
||||
@ -26,30 +32,26 @@ export default function Events() {
|
||||
return axios.get(path, { params }).then((res) => res.data);
|
||||
}, []);
|
||||
|
||||
const reviewSearchParams = {};
|
||||
const getKey = useCallback(
|
||||
(index: number, prevData: ReviewSegment[]) => {
|
||||
if (index > 0) {
|
||||
const lastDate = prevData[prevData.length - 1].start_time;
|
||||
const pagedParams = reviewSearchParams
|
||||
? { before: lastDate, after: timeRange.after, limit: API_LIMIT }
|
||||
: {
|
||||
...reviewSearchParams,
|
||||
before: lastDate,
|
||||
after: timeRange.after,
|
||||
limit: API_LIMIT,
|
||||
};
|
||||
reviewSearchParams;
|
||||
const pagedParams = {
|
||||
cameras: reviewSearchParams["cameras"],
|
||||
before: lastDate,
|
||||
after: reviewSearchParams["after"] || timeRange.after,
|
||||
limit: API_LIMIT,
|
||||
};
|
||||
return ["review", pagedParams];
|
||||
}
|
||||
|
||||
const params = reviewSearchParams
|
||||
? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after }
|
||||
: {
|
||||
...reviewSearchParams,
|
||||
limit: API_LIMIT,
|
||||
before: timeRange.before,
|
||||
after: timeRange.after,
|
||||
};
|
||||
const params = {
|
||||
cameras: reviewSearchParams["cameras"],
|
||||
limit: API_LIMIT,
|
||||
before: reviewSearchParams["before"] || timeRange.before,
|
||||
after: reviewSearchParams["after"] || timeRange.after,
|
||||
};
|
||||
return ["review", params];
|
||||
},
|
||||
[reviewSearchParams]
|
||||
@ -197,10 +199,12 @@ export default function Events() {
|
||||
timeRange={timeRange}
|
||||
reachedEnd={isDone}
|
||||
isValidating={isValidating}
|
||||
filter={reviewFilter}
|
||||
loadNextPage={() => setSize(size + 1)}
|
||||
markItemAsReviewed={markItemAsReviewed}
|
||||
onSelectReview={setSelectedReviewId}
|
||||
pullLatestData={updateSegments}
|
||||
updateFilter={setReviewFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,3 +19,11 @@ export type ReviewData = {
|
||||
significant_motion_areas: number[];
|
||||
zones: string[];
|
||||
};
|
||||
|
||||
export type ReviewFilter = {
|
||||
cameras?: string[];
|
||||
labels?: string[];
|
||||
before?: number;
|
||||
after?: number;
|
||||
showReviewed?: boolean;
|
||||
};
|
||||
|
||||
@ -1,20 +1,14 @@
|
||||
import { useFrigateEvents } from "@/api/ws";
|
||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LuCalendar, LuFilter, LuRefreshCcw, LuVideo } from "react-icons/lu";
|
||||
import { LuRefreshCcw } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
|
||||
@ -24,10 +18,12 @@ type DesktopEventViewProps = {
|
||||
timeRange: { before: number; after: number };
|
||||
reachedEnd: boolean;
|
||||
isValidating: boolean;
|
||||
filter?: ReviewFilter;
|
||||
loadNextPage: () => void;
|
||||
markItemAsReviewed: (reviewId: string) => void;
|
||||
onSelectReview: (reviewId: string) => void;
|
||||
pullLatestData: () => void;
|
||||
updateFilter: (filter: ReviewFilter) => void;
|
||||
};
|
||||
export default function DesktopEventView({
|
||||
reviewPages,
|
||||
@ -35,10 +31,12 @@ export default function DesktopEventView({
|
||||
timeRange,
|
||||
reachedEnd,
|
||||
isValidating,
|
||||
filter,
|
||||
loadNextPage,
|
||||
markItemAsReviewed,
|
||||
onSelectReview,
|
||||
pullLatestData,
|
||||
updateFilter,
|
||||
}: DesktopEventViewProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
|
||||
@ -234,17 +232,7 @@ export default function DesktopEventView({
|
||||
Motion
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div>
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuVideo className=" mr-[10px]" />
|
||||
All Cameras
|
||||
</Button>
|
||||
<ReviewCalendarButton />
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuFilter className=" mr-[10px]" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
<ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} />
|
||||
</div>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
@ -334,29 +322,3 @@ export default function DesktopEventView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewCalendarButton() {
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
||||
const future = new Date();
|
||||
future.setFullYear(tomorrow.getFullYear() + 10);
|
||||
return { from: tomorrow, to: future };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuCalendar className=" mr-[10px]" />
|
||||
{formatUnixTimestampToDateTime(Date.now() / 1000, {
|
||||
strftime_fmt: "%b %-d",
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Calendar mode="single" disabled={disabledDates} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user