feat: lazy load images in potential albums and missing locations
Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
1 parent
894b44877b
commit
e8a3d50e11
14 files changed
+236
-15
No files matched your search
@@ -46,6 +46,7 @@
|
||||
"react-day-picker": "9.0.8",
|
||||
"react-dom": "^18",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-query": "^3.39.3",
|
||||
"recharts": "^2.12.7",
|
||||
"sass": "^1.77.8",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
|
||||
@@ -9,6 +9,7 @@ import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import { CalendarArrowDown, CalendarArrowUp, Hourglass } from "lucide-react";
|
||||
import Video from "yet-another-react-lightbox/plugins/video";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import LazyImage from "@/components/ui/lazy-image";
|
||||
|
||||
export default function PotentialAlbumsAssets() {
|
||||
const { exImmichUrl } = useConfig();
|
||||
@@ -123,6 +124,7 @@ export default function PotentialAlbumsAssets() {
|
||||
onClick={handleClick}
|
||||
enableImageSelection={true}
|
||||
onSelect={handleSelect}
|
||||
thumbnailImageComponent={LazyImage}
|
||||
tagStyle={{
|
||||
color: "white",
|
||||
fontSize: "12px",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { listMissingLocationAssets } from "@/handlers/api/asset.handler";
|
||||
import { formatDate, parseDate } from "@/helpers/date.helper";
|
||||
import { addDays } from "date-fns";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import LazyImage from "@/components/ui/lazy-image";
|
||||
|
||||
export default function MissingLocationAssets() {
|
||||
const { exImmichUrl } = useConfig();
|
||||
@@ -125,6 +126,7 @@ export default function MissingLocationAssets() {
|
||||
onClick={handleClick}
|
||||
enableImageSelection={true}
|
||||
onSelect={handleSelect}
|
||||
thumbnailImageComponent={LazyImage}
|
||||
tagStyle={{
|
||||
color: "white",
|
||||
fontSize: "12px",
|
||||
|
||||
@@ -10,11 +10,14 @@ import { ArrowRight, TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { LoginForm } from "../auth/LoginForm";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import { queryClient } from "@/config/rQuery";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
|
||||
type RootLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
const { immichURL, exImmichUrl } = useConfig();
|
||||
const [user, setUser] = useState<IUser | null>(null);
|
||||
@@ -34,9 +37,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const handleLoginCompletion = (user: IUser) => {
|
||||
setUser(user);
|
||||
}
|
||||
const handleLoginCompletion = (user: IUser) => setUser(user);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -86,15 +87,17 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
if (!user) return <LoginForm onLogin={handleLoginCompletion} />;
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{
|
||||
...user,
|
||||
updateContext: setUser,
|
||||
}}>
|
||||
<div className="grid max-h-screen min-h-screen w-full md:grid-cols-[200px_1fr] lg:grid-cols-[240px_1fr]">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col">{children}</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
</UserContext.Provider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserContext.Provider value={{
|
||||
...user,
|
||||
updateContext: setUser,
|
||||
}}>
|
||||
<div className="grid max-h-screen min-h-screen w-full md:grid-cols-[200px_1fr] lg:grid-cols-[240px_1fr]">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col">{children}</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
</UserContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Input } from "../ui/input";
|
||||
import { IPersonListFilters } from "@/handlers/api/people.handler";
|
||||
import AssetFilter from "../shared/common/AssetFilter";
|
||||
|
||||
export function PeopleFilters() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { getFilters } from '@/handlers/api/common.handler';
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
const fields = [
|
||||
"make",
|
||||
"model",
|
||||
"lensModel",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"projectionType",
|
||||
"colorspace",
|
||||
"bitsPerSample",
|
||||
"rating",
|
||||
];
|
||||
|
||||
const formattedFields = fields.map(field => ({
|
||||
label: field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()),
|
||||
value: field
|
||||
}));
|
||||
|
||||
|
||||
export default function AssetFilter() {
|
||||
const [selectedField, setSelectedField] = useState<string | null>(null);
|
||||
|
||||
const { data } = useQuery('filters', getFilters)
|
||||
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!selectedField) return formattedFields;
|
||||
const { filters } = data;
|
||||
|
||||
if (!filters) return formattedFields;
|
||||
const field = filters[selectedField];
|
||||
if (!field) return [];
|
||||
return field.map((value: string) => ({
|
||||
label: value,
|
||||
value
|
||||
}));
|
||||
}, [selectedField, data]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
closeOnSelect={!!selectedField}
|
||||
onSelect={(value) => {
|
||||
if (!selectedField) setSelectedField(value);
|
||||
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedField(null);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Check, Filter } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
|
||||
export interface IComboBoxOption { label: string; value: string }
|
||||
|
||||
export interface IComboBoxProps {
|
||||
options: IComboBoxOption[]
|
||||
onSelect?: (value: string) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
|
||||
export function Combobox(
|
||||
{ options, onSelect, onOpenChange, closeOnSelect }: IComboBoxProps
|
||||
) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [value, setValue] = React.useState("")
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(openState) => {
|
||||
setOpen(openState)
|
||||
onOpenChange?.(openState)
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Filter />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search framework..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No framework found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? "" : currentValue)
|
||||
onSelect?.(currentValue)
|
||||
if (closeOnSelect) {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 min-h-4 min-w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{option.label.trim()}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React, { useEffect } from 'react'
|
||||
import { Image, ImageExtended, ThumbnailImageProps } from 'react-grid-gallery'
|
||||
|
||||
interface LazyImageProps extends ThumbnailImageProps<ImageExtended<Image>> {}
|
||||
|
||||
export default function LazyImage(
|
||||
props: LazyImageProps
|
||||
) {
|
||||
const [isVisible, setIsVisible] = React.useState(false)
|
||||
const imageRef = React.useRef<HTMLImageElement>(null)
|
||||
|
||||
const setupObserver = () => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (imageRef.current) {
|
||||
observer.observe(imageRef.current)
|
||||
}
|
||||
return observer
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const observer = setupObserver()
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isVisible) return (
|
||||
<div style={{ height: props.height }} ref={imageRef} />
|
||||
)
|
||||
|
||||
return (
|
||||
<img {...props.imageProps} alt={props.imageProps.alt || ""} title="" />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {
|
||||
QueryClient,
|
||||
} from 'react-query'
|
||||
|
||||
export const queryClient = new QueryClient()
|
||||
@@ -34,4 +34,7 @@ export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/vi
|
||||
|
||||
// Location
|
||||
|
||||
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
|
||||
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
|
||||
|
||||
// Common
|
||||
export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type FilterValue = string | string[] | number | number[] | undefined;
|
||||
|
||||
interface ExifFiltersType {
|
||||
updateContext: (key: string, value: FilterValue) => void;
|
||||
[key: string]: FilterValue | ((key: string, value: FilterValue) => void);
|
||||
}
|
||||
|
||||
const ExifFilters = createContext<ExifFiltersType>({
|
||||
updateContext: () => {},
|
||||
});
|
||||
|
||||
export default ExifFilters;
|
||||
|
||||
export const useExifFilters = () => {
|
||||
const context = useContext(ExifFilters) as ExifFiltersType;
|
||||
return context;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SEARCH_PLACES_PATH } from "@/config/routes";
|
||||
import { GET_FILTERS, SEARCH_PLACES_PATH } from "@/config/routes";
|
||||
import API from "@/lib/api";
|
||||
import { IPlace } from "@/types/common";
|
||||
|
||||
@@ -6,3 +6,7 @@ export const searchPlaces = async (name: string): Promise<IPlace[]> => {
|
||||
return API.get(SEARCH_PLACES_PATH, { name });
|
||||
}
|
||||
|
||||
|
||||
export const getFilters = async () => {
|
||||
return API.get(GET_FILTERS);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import MissingLocationAssets from "@/components/assets/missing-location/MissingL
|
||||
import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates";
|
||||
import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog";
|
||||
import PageLayout from "@/components/layouts/PageLayout";
|
||||
import AssetFilter from "@/components/shared/common/AssetFilter";
|
||||
import Header from "@/components/shared/Header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,6 +49,7 @@ export default function MissingLocations() {
|
||||
leftComponent="Missing Location"
|
||||
rightComponent={
|
||||
<>
|
||||
<AssetFilter />
|
||||
<Badge variant={"outline"}>
|
||||
{config.selectedIds.length} Selected
|
||||
</Badge>
|
||||
|
||||
Reference in new issue
Block a user