feat: lazy load images in potential albums and missing locations

Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
Varun Raj committed 2024-09-08 11:59:43 +05:30
1 parent 894b44877b
commit e8a3d50e11
14 files changed
+236 -15

No files matched your search

BIN
View File
Binary file not shown.
+1
View File
@@ -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",
+16 -13
View File
@@ -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>
);
}
+1
View File
@@ -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);
}}
/>
)
}
+78
View File
@@ -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>
)
}
+44
View File
@@ -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="" />
)
}
+5
View File
@@ -0,0 +1,5 @@
import {
QueryClient,
} from 'react-query'
export const queryClient = new QueryClient()
+4 -1
View File
@@ -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";
+19
View File
@@ -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;
}
+5 -1
View File
@@ -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);
}
+2
View File
@@ -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>