+
Made with ♥ by{" "}
+
+ v{version}
+
>
);
diff --git a/src/components/shared/common/AssetFilter.tsx b/src/components/shared/common/AssetFilter.tsx
new file mode 100644
index 0000000..bff8f0e
--- /dev/null
+++ b/src/components/shared/common/AssetFilter.tsx
@@ -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
(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 (
+ {
+ if (!selectedField) setSelectedField(value);
+
+ }}
+ onOpenChange={(open) => {
+ if (!open) setSelectedField(null);
+ }}
+ />
+ )
+}
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx
new file mode 100644
index 0000000..b7fd690
--- /dev/null
+++ b/src/components/ui/combobox.tsx
@@ -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 (
+ {
+ setOpen(openState)
+ onOpenChange?.(openState)
+ }}>
+
+
+
+
+
+
+
+ No framework found.
+
+ {options.map((option) => (
+ {
+ setValue(currentValue === value ? "" : currentValue)
+ onSelect?.(currentValue)
+ if (closeOnSelect) {
+ setOpen(false)
+ }
+ }}
+ >
+
+ {option.label.trim()}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/components/ui/datepicker.tsx b/src/components/ui/datepicker.tsx
index 3053e3e..090993f 100644
--- a/src/components/ui/datepicker.tsx
+++ b/src/components/ui/datepicker.tsx
@@ -16,9 +16,10 @@ import {
interface IProps {
date?: Date | null
onSelect?: (date?: Date | null) => any
+ iconOnly?: boolean
}
export function DatePicker(
- { date: _date, onSelect }: IProps
+ { date: _date, onSelect, iconOnly }: IProps
) {
const [date, setDate] = React.useState(_date || null)
@@ -32,12 +33,12 @@ export function DatePicker(
diff --git a/src/components/ui/lazy-image.tsx b/src/components/ui/lazy-image.tsx
new file mode 100644
index 0000000..9027779
--- /dev/null
+++ b/src/components/ui/lazy-image.tsx
@@ -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> {}
+
+export default function LazyImage(
+ props: LazyImageProps
+) {
+ const [isVisible, setIsVisible] = React.useState(false)
+ const imageRef = React.useRef(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 (
+
+ )
+
+ return (
+
+ )
+}
diff --git a/src/config/environment.ts b/src/config/environment.ts
index 7a5945d..08dc3b2 100644
--- a/src/config/environment.ts
+++ b/src/config/environment.ts
@@ -5,6 +5,7 @@ export const ENV = {
DATABASE_URL: (process.env.DATABASE_URL || `postgresql://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${(process.env.DB_HOST || 'immich_postgres')}:${(process.env.DB_PORT || '5432')}/${process.env.DB_DATABASE_NAME}`),
JWT_SECRET: process.env.JWT_SECRET as string,
SECURE_COOKIE: process.env.SECURE_COOKIE === 'true',
+ VERSION: process.env.VERSION
};
diff --git a/src/config/rQuery.ts b/src/config/rQuery.ts
new file mode 100644
index 0000000..3118e51
--- /dev/null
+++ b/src/config/rQuery.ts
@@ -0,0 +1,5 @@
+import {
+ QueryClient,
+} from 'react-query'
+
+export const queryClient = new QueryClient()
\ No newline at end of file
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 1ecb845..4bfe2f8 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -40,4 +40,6 @@ export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
export const ASSET_STATISTICS = BASE_PROXY_ENDPOINT + "/assets/statistics";
export const LIVE_PHOTO_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/livephoto";
-export const HEATMAP_DATA = BASE_API_ENDPOINT + "/analytics/statistics/heatmap";
\ No newline at end of file
+export const HEATMAP_DATA = BASE_API_ENDPOINT + "/analytics/statistics/heatmap";
+// Common
+export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index 9c742cf..af5b2f8 100644
--- a/src/contexts/ConfigContext.tsx
+++ b/src/contexts/ConfigContext.tsx
@@ -3,11 +3,13 @@ import { createContext, useContext } from "react";
interface ConfigContextType {
immichURL: string;
exImmichUrl: string;
+ version?: string;
}
const ConfigContext = createContext({
immichURL: "",
exImmichUrl: "",
+ version: "",
});
export default ConfigContext;
diff --git a/src/contexts/ExifFiltersContext.tsx b/src/contexts/ExifFiltersContext.tsx
new file mode 100644
index 0000000..fe8efec
--- /dev/null
+++ b/src/contexts/ExifFiltersContext.tsx
@@ -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({
+ updateContext: () => {},
+});
+
+export default ExifFilters;
+
+export const useExifFilters = () => {
+ const context = useContext(ExifFilters) as ExifFiltersType;
+ return context;
+}
\ No newline at end of file
diff --git a/src/handlers/api/common.handler.ts b/src/handlers/api/common.handler.ts
index c2ba640..3626e32 100644
--- a/src/handlers/api/common.handler.ts
+++ b/src/handlers/api/common.handler.ts
@@ -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 => {
return API.get(SEARCH_PLACES_PATH, { name });
}
+
+export const getFilters = async () => {
+ return API.get(GET_FILTERS);
+}
\ No newline at end of file
diff --git a/src/handlers/api/people.handler.ts b/src/handlers/api/people.handler.ts
index 4fd5ecc..a295e58 100644
--- a/src/handlers/api/people.handler.ts
+++ b/src/handlers/api/people.handler.ts
@@ -26,7 +26,7 @@ export const listPeople = (filters: IPersonListFilters): Promise) => {
return API.put(UPDATE_PERSON_PATH(id), data)
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index d3eaa84..802034b 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -4,14 +4,12 @@ import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
import { ENV } from "@/config/environment";
import ConfigContext from "@/contexts/ConfigContext";
-import { useEffect, useRef, useState } from "react";
-import { getMe } from "@/handlers/api/user.handler";
-import { db } from "@/config/db";
-
+import { useRef } from "react";
interface AppPropsWithProps extends AppProps {
props: {
immichURL: string;
exImmichUrl: string;
+ version?: string;
};
}
const App = ({ Component, pageProps, ...props }: AppPropsWithProps) => {
@@ -29,10 +27,12 @@ const App = ({ Component, pageProps, ...props }: AppPropsWithProps) => {
};
App.getInitialProps = async () => {
+
return {
props: {
exImmichUrl: ENV.EXTERNAL_IMMICH_URL,
immichURL: ENV.IMMICH_URL,
+ version: ENV.VERSION
},
};
};
diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx
index 8e5ef26..b7aa580 100644
--- a/src/pages/albums/potential-albums.tsx
+++ b/src/pages/albums/potential-albums.tsx
@@ -13,14 +13,17 @@ import PotentialAlbumContext, {
} from "@/contexts/PotentialAlbumContext";
import { addAssetToAlbum, createAlbum } from "@/handlers/api/album.handler";
import { IAlbum, IAlbumCreate } from "@/types/album";
+import { useRouter } from "next/router";
import React from "react";
export default function PotentialAlbums() {
const { toast } = useToast();
const { id } = useCurrentUser();
+ const { query } = useRouter();
+ const { startDate } = query as { startDate: string };
const [config, setConfig] = React.useState({
- startDate: undefined,
+ startDate: startDate || undefined,
selectedIds: [],
assets: [],
});
@@ -62,6 +65,7 @@ export default function PotentialAlbums() {
}]
})
}
+
return (
{config.selectedIds.length} Selected
- {config.selectedIds.length === config.assets.length ? (
+ {config.selectedIds.length && config.selectedIds.length === config.assets.length ? (