Explore UI changes (#14393)

* Add time ago to explore summary view on desktop

* add search settings for columns and default view selection

* add descriptions

* clarify wording

* padding tweak

* padding tweaks for mobile

* fix size of activity indicator

* smaller
This commit is contained in:
Josh Hawkins 2024-10-16 11:54:01 -05:00 committed by GitHub
parent 9f866be110
commit e836523bc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 175 additions and 72 deletions

View File

@ -118,7 +118,7 @@ export default function SearchThumbnailFooter({
<TimeAgo time={searchResult.start_time * 1000} dense /> <TimeAgo time={searchResult.start_time * 1000} dense />
) : ( ) : (
<div> <div>
<ActivityIndicator size={24} /> <ActivityIndicator size={14} />
</div> </div>
)} )}
{formattedDate} {formattedDate}

View File

@ -383,7 +383,7 @@ export default function ObjectLifecycle({
{eventSequence.map((item, index) => ( {eventSequence.map((item, index) => (
<CarouselItem key={index}> <CarouselItem key={index}>
<Card className="p-1 text-sm md:p-2" key={index}> <Card className="p-1 text-sm md:p-2" key={index}>
<CardContent className="flex flex-row items-center gap-3 p-1 md:p-6"> <CardContent className="flex flex-row items-center gap-3 p-1 md:p-2">
<div className="flex flex-1 flex-row items-center justify-start p-3 pl-1"> <div className="flex flex-1 flex-row items-center justify-start p-3 pl-1">
<div <div
className="rounded-lg p-2" className="rounded-lg p-2"

View File

@ -1,4 +1,4 @@
import { FaArrowRight, FaCog } from "react-icons/fa"; import { FaArrowRight, FaFilter } from "react-icons/fa";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { PlatformAwareSheet } from "./PlatformAwareDialog"; import { PlatformAwareSheet } from "./PlatformAwareDialog";
@ -66,7 +66,7 @@ export default function SearchFilterDialog({
size="sm" size="sm"
variant={moreFiltersSelected ? "select" : "default"} variant={moreFiltersSelected ? "select" : "default"}
> >
<FaCog <FaFilter
className={cn( className={cn(
moreFiltersSelected ? "text-white" : "text-secondary-foreground", moreFiltersSelected ? "text-white" : "text-secondary-foreground",
)} )}

View File

@ -0,0 +1,109 @@
import { Button } from "../ui/button";
import { useState } from "react";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { FaCog } from "react-icons/fa";
import { Slider } from "../ui/slider";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
type SearchSettingsProps = {
className?: string;
columns: number;
defaultView: string;
setColumns: (columns: number) => void;
setDefaultView: (view: string) => void;
};
export default function SearchSettings({
className,
columns,
setColumns,
defaultView,
setDefaultView,
}: SearchSettingsProps) {
const [open, setOpen] = useState(false);
const trigger = (
<Button className="flex items-center gap-2" size="sm">
<FaCog className="text-secondary-foreground" />
Settings
</Button>
);
const content = (
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">Default Search View</div>
<div className="space-y-1 text-xs text-muted-foreground">
When no filters are selected, display a summary of the most recent
tracked objects per label, or display an unfiltered grid.
</div>
</div>
<Select
value={defaultView}
onValueChange={(value) => setDefaultView(value)}
>
<SelectTrigger className="w-full">
{defaultView == "summary" ? "Summary" : "Unfiltered Grid"}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["summary", "grid"].map((value) => (
<SelectItem
key={value}
className="cursor-pointer"
value={value}
>
{value == "summary" ? "Summary" : "Unfiltered Grid"}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="flex w-full flex-col space-y-4">
<div className="space-y-0.5">
<div className="text-md">Grid Columns</div>
<div className="space-y-1 text-xs text-muted-foreground">
Select the number of columns in the results grid.
</div>
</div>
<div className="flex items-center space-x-4">
<Slider
value={[columns]}
onValueChange={([value]) => setColumns(value)}
max={6}
min={2}
step={1}
className="flex-grow"
/>
<span className="w-9 text-center text-sm font-medium">{columns}</span>
</div>
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={
isDesktop
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
: "max-h-[75dvh] overflow-hidden p-4"
}
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
/>
);
}

View File

@ -7,6 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar"; import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search"; import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import { ModelState } from "@/types/ws"; import { ModelState } from "@/types/ws";
@ -28,6 +29,18 @@ export default function Explore() {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
// grid
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const gridColumns = useMemo(() => columnCount ?? 4, [columnCount]);
// default layout
const [defaultView, setDefaultView] = usePersistence(
"exploreDefaultView",
"summary",
);
const timezone = useTimezone(config); const timezone = useTimezone(config);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -65,8 +78,12 @@ export default function Explore() {
const searchQuery: SearchQuery = useMemo(() => { const searchQuery: SearchQuery = useMemo(() => {
// no search parameters // no search parameters
if (searchSearchParams && Object.keys(searchSearchParams).length === 0) { if (searchSearchParams && Object.keys(searchSearchParams).length === 0) {
if (defaultView == "grid") {
return ["events", {}];
} else {
return null; return null;
} }
}
// parameters, but no search term and not similarity // parameters, but no search term and not similarity
if ( if (
@ -117,7 +134,7 @@ export default function Explore() {
include_thumbnails: 0, include_thumbnails: 0,
}, },
]; ];
}, [searchTerm, searchSearchParams, similaritySearch, timezone]); }, [searchTerm, searchSearchParams, similaritySearch, timezone, defaultView]);
// paging // paging
@ -385,6 +402,8 @@ export default function Explore() {
searchResults={searchResults} searchResults={searchResults}
isLoading={(isLoadingInitialData || isLoadingMore) ?? true} isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
hasMore={!isReachingEnd} hasMore={!isReachingEnd}
columns={gridColumns}
defaultView={defaultView}
setSearch={setSearch} setSearch={setSearch}
setSimilaritySearch={(search) => { setSimilaritySearch={(search) => {
setSearchFilter({ setSearchFilter({
@ -395,6 +414,8 @@ export default function Explore() {
}} }}
setSearchFilter={setSearchFilter} setSearchFilter={setSearchFilter}
onUpdateFilter={setSearchFilter} onUpdateFilter={setSearchFilter}
setColumns={setColumnCount}
setDefaultView={setDefaultView}
loadMore={loadMore} loadMore={loadMore}
refresh={mutate} refresh={mutate}
/> />

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { isIOS, isMobileOnly, isSafari } from "react-device-detect"; import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -17,6 +17,7 @@ import useImageLoaded from "@/hooks/use-image-loaded";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useEventUpdate } from "@/api/ws"; import { useEventUpdate } from "@/api/ws";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import TimeAgo from "@/components/dynamic/TimeAgo";
type ExploreViewProps = { type ExploreViewProps = {
searchDetail: SearchResult | undefined; searchDetail: SearchResult | undefined;
@ -197,6 +198,7 @@ function ExploreThumbnailImage({
className="absolute inset-0" className="absolute inset-0"
imgLoaded={imgLoaded} imgLoaded={imgLoaded}
/> />
<img <img
ref={imgRef} ref={imgRef}
className={cn( className={cn(
@ -218,6 +220,17 @@ function ExploreThumbnailImage({
onImgLoad(); onImgLoad();
}} }}
/> />
{isDesktop && (
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
{event.end_time ? (
<TimeAgo time={event.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={10} />
</div>
)}
</div>
)}
</> </>
); );
} }

View File

@ -5,17 +5,12 @@ import SearchDetailDialog, {
SearchTab, SearchTab,
} from "@/components/overlay/detail/SearchDetailDialog"; } from "@/components/overlay/detail/SearchDetailDialog";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { LuColumns, LuSearchX } from "react-icons/lu"; import { LuSearchX } from "react-icons/lu";
import useSWR from "swr"; import useSWR from "swr";
import ExploreView from "../explore/ExploreView"; import ExploreView from "../explore/ExploreView";
import useKeyboardListener, { import useKeyboardListener, {
@ -26,14 +21,8 @@ import InputWithTags from "@/components/input/InputWithTags";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil"; import { formatDateToLocaleString } from "@/utils/dateUtil";
import { Slider } from "@/components/ui/slider";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { usePersistence } from "@/hooks/use-persistence";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter"; import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
import SearchSettings from "@/components/settings/SearchSettings";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -42,12 +31,16 @@ type SearchViewProps = {
searchResults?: SearchResult[]; searchResults?: SearchResult[];
isLoading: boolean; isLoading: boolean;
hasMore: boolean; hasMore: boolean;
columns: number;
defaultView?: string;
setSearch: (search: string) => void; setSearch: (search: string) => void;
setSimilaritySearch: (search: SearchResult) => void; setSimilaritySearch: (search: SearchResult) => void;
setSearchFilter: (filter: SearchFilter) => void; setSearchFilter: (filter: SearchFilter) => void;
onUpdateFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void;
loadMore: () => void; loadMore: () => void;
refresh: () => void; refresh: () => void;
setColumns: (columns: number) => void;
setDefaultView: (name: string) => void;
}; };
export default function SearchView({ export default function SearchView({
search, search,
@ -56,12 +49,16 @@ export default function SearchView({
searchResults, searchResults,
isLoading, isLoading,
hasMore, hasMore,
columns,
defaultView = "summary",
setSearch, setSearch,
setSimilaritySearch, setSimilaritySearch,
setSearchFilter, setSearchFilter,
onUpdateFilter, onUpdateFilter,
loadMore, loadMore,
refresh, refresh,
setColumns,
setDefaultView,
}: SearchViewProps) { }: SearchViewProps) {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -70,18 +67,15 @@ export default function SearchView({
// grid // grid
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
const gridClassName = cn( const gridClassName = cn(
"grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", "grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2",
isMobileOnly && "grid-cols-2", isMobileOnly && "grid-cols-2",
{ {
"sm:grid-cols-2": effectiveColumnCount <= 2, "sm:grid-cols-2": columns <= 2,
"sm:grid-cols-3": effectiveColumnCount === 3, "sm:grid-cols-3": columns === 3,
"sm:grid-cols-4": effectiveColumnCount === 4, "sm:grid-cols-4": columns === 4,
"sm:grid-cols-5": effectiveColumnCount === 5, "sm:grid-cols-5": columns === 5,
"sm:grid-cols-6": effectiveColumnCount === 6, "sm:grid-cols-6": columns === 6,
}, },
); );
@ -342,7 +336,7 @@ export default function SearchView({
{hasExistingSearch && ( {hasExistingSearch && (
<ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]"> <ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
<div className="flex flex-row"> <div className="flex flex-row gap-2">
<SearchFilterGroup <SearchFilterGroup
className={cn( className={cn(
"w-full justify-between md:justify-start lg:justify-end", "w-full justify-between md:justify-start lg:justify-end",
@ -350,6 +344,12 @@ export default function SearchView({
filter={searchFilter} filter={searchFilter}
onUpdateFilter={onUpdateFilter} onUpdateFilter={onUpdateFilter}
/> />
<SearchSettings
columns={columns}
setColumns={setColumns}
defaultView={defaultView}
setDefaultView={setDefaultView}
/>
<ScrollBar orientation="horizontal" className="h-0" /> <ScrollBar orientation="horizontal" className="h-0" />
</div> </div>
</ScrollArea> </ScrollArea>
@ -425,53 +425,13 @@ export default function SearchView({
<div className="flex h-12 w-full justify-center"> <div className="flex h-12 w-full justify-center">
{hasMore && isLoading && <ActivityIndicator />} {hasMore && isLoading && <ActivityIndicator />}
</div> </div>
{isDesktop && columnCount && (
<div
className={cn(
"fixed bottom-12 right-3 z-50 flex flex-row gap-2 lg:bottom-9",
)}
>
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<div className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-75 transition-all duration-300 hover:bg-muted hover:opacity-100">
<LuColumns className="size-5 md:m-[6px]" />
</div>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Adjust Grid Columns</TooltipContent>
</Tooltip>
<PopoverContent className="mr-2 w-80">
<div className="space-y-4">
<div className="font-medium leading-none">
Grid Columns
</div>
<div className="flex items-center space-x-4">
<Slider
value={[effectiveColumnCount]}
onValueChange={([value]) => setColumnCount(value)}
max={6}
min={2}
step={1}
className="flex-grow"
/>
<span className="w-9 text-center text-sm font-medium">
{effectiveColumnCount}
</span>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)}
</> </>
)} )}
</div> </div>
{searchFilter && {searchFilter &&
Object.keys(searchFilter).length === 0 && Object.keys(searchFilter).length === 0 &&
!searchTerm && ( !searchTerm &&
defaultView == "summary" && (
<div className="scrollbar-container flex size-full flex-col overflow-y-auto"> <div className="scrollbar-container flex size-full flex-col overflow-y-auto">
<ExploreView <ExploreView
searchDetail={searchDetail} searchDetail={searchDetail}