+9
-3
@@ -14,6 +14,7 @@
|
||||
"url": "https://varunraj.in"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/qs": "^6.9.15",
|
||||
"@types/react-calendar-heatmap": "^1.6.7",
|
||||
"@vis.gl/react-google-maps": "^1.4.2",
|
||||
"axios": "^1.7.4",
|
||||
"chrono-node": "^1.4.9",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -46,25 +48,28 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-defaulticon-compatibility":"0.1.2",
|
||||
"leaflet-defaulticon-compatibility": "0.1.2",
|
||||
"lucide-react": "^0.428.0",
|
||||
"next": "14.2.5",
|
||||
"next-themes": "^0.3.0",
|
||||
"pg": "^8.12.0",
|
||||
"qs": "^6.13.0",
|
||||
"rc-mentions": "^2.18.0",
|
||||
"react": "^18",
|
||||
"react-calendar-heatmap": "^1.9.0",
|
||||
"react-day-picker": "9.0.8",
|
||||
"react-dom": "^18",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-mentions": "^4.4.10",
|
||||
"react-query": "^3.39.3",
|
||||
"recharts": "^2.12.7",
|
||||
"remotion": "4.0.232",
|
||||
"sass": "^1.77.8",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"yet-another-react-lightbox": "^3.21.4"
|
||||
"yet-another-react-lightbox": "^3.21.4",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
||||
@@ -73,11 +78,12 @@
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/leaflet":"^1.9.15",
|
||||
"@types/leaflet": "^1.9.15",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-mentions": "^4.4.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.24.0",
|
||||
"eslint": "^8",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react'
|
||||
import inputStyle from './findInputStyle'
|
||||
import { MentionsInput, Mention } from 'react-mentions'
|
||||
import { searchPeople } from '@/handlers/api/people.handler';
|
||||
import mentionStyle from './findMentionStyle';
|
||||
import classNames from './FindInput.module.css';
|
||||
|
||||
|
||||
interface FindInputProps {
|
||||
onSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
export default function FindInput({ onSearch }: FindInputProps) {
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleSearchPeople = async (e: any, callback: any) => {
|
||||
if (!e.length) return [];
|
||||
return searchPeople(e).then((people) => people.map((person: any) => ({
|
||||
id: person.id,
|
||||
display: person.name,
|
||||
}))).then((people) => callback(people));
|
||||
}
|
||||
|
||||
return (
|
||||
<MentionsInput
|
||||
value={query}
|
||||
singleLine={true}
|
||||
placeholder='Search for photos, use @ to search for people'
|
||||
style={inputStyle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSearch(query);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setQuery(e.target.value)}>
|
||||
<Mention
|
||||
trigger="@"
|
||||
style={mentionStyle}
|
||||
data={handleSearchPeople}
|
||||
renderSuggestion={(
|
||||
highlightedDisplay,
|
||||
focused
|
||||
) => (
|
||||
<div className={`user ${focused ? 'focused' : ''} `}>
|
||||
{highlightedDisplay.display}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</MentionsInput>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const findInputStyle = {
|
||||
control: {
|
||||
backgroundColor: 'hsl(var(--input))',
|
||||
fontSize: 20,
|
||||
fontWeight: 'normal',
|
||||
outline: 'none',
|
||||
borderRadius: '15px',
|
||||
|
||||
},
|
||||
|
||||
'&singleLine': {
|
||||
// display: 'inline-block',
|
||||
width: '100%',
|
||||
|
||||
highlighter: {
|
||||
padding: '10px 15px',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
},
|
||||
input: {
|
||||
padding: '10px 15px',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
suggestions: {
|
||||
|
||||
list: {
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
fontSize: 14,
|
||||
border: '1px solid hsl(var(--border))',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
item: {
|
||||
padding: '5px 15px',
|
||||
border: 'none',
|
||||
'&focused': {
|
||||
backgroundColor: 'hsl(var(--chart-2))',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default findInputStyle;
|
||||
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
backgroundColor: 'hsla(var(--chart-2) / 0.3)',
|
||||
position: 'relative',
|
||||
}
|
||||
@@ -23,11 +23,11 @@ export default function PageLayout({ children, className, title }: IProps) {
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
{header}
|
||||
<main
|
||||
className={cn(
|
||||
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 overflow-y-auto mx-auto",
|
||||
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 overflow-y-auto mx-auto w-full",
|
||||
{
|
||||
"mb-14": !!header,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import Head from "next/head";
|
||||
import { sidebarNavs } from "@/config/constants/sidebarNavs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface IProps {
|
||||
leftComponent?: React.ReactNode | string;
|
||||
@@ -62,7 +63,9 @@ export default function Header({ leftComponent, rightComponent, title }: IProps)
|
||||
<title>{pageTitle} - Immich Power Tools</title>
|
||||
</Head>
|
||||
)}
|
||||
<header key="header" className="sticky z-10 top-0 w-full flex h-14 items-center gap-4 border-b bg-white dark:bg-black px-4 lg:h-[60px] lg:px-6">
|
||||
<header key="header" className={
|
||||
clsx("sticky z-10 top-0 w-full flex h-14 items-center gap-4 border-b bg-white dark:bg-black px-4 lg:h-[60px] lg:px-6")
|
||||
}>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0 md:hidden">
|
||||
|
||||
@@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import ProfileInfo from "./ProfileInfo";
|
||||
|
||||
const ThemeSwitcher = dynamic(() => import("@/components/shared/ThemeSwitcher"), {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Album, GalleryHorizontal, GalleryVerticalEnd, Home, Image, LocateOff, MapPinX, Rewind, User } from "lucide-react";
|
||||
import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User } from "lucide-react";
|
||||
|
||||
export const sidebarNavs = [
|
||||
{
|
||||
title: "Rewind 2024",
|
||||
link: "/rewind",
|
||||
icon: <Rewind className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Find",
|
||||
link: "/find",
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Manage People",
|
||||
link: "/",
|
||||
@@ -9,7 +19,7 @@ export const sidebarNavs = [
|
||||
{
|
||||
title: "Analytics",
|
||||
link: "/analytics/exif",
|
||||
icon: <Image className="h-4 w-4" />,
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Potential Albums",
|
||||
@@ -26,9 +36,11 @@ export const sidebarNavs = [
|
||||
link: "/albums",
|
||||
icon: <GalleryHorizontal className="h-4 w-4" />,
|
||||
},
|
||||
|
||||
{
|
||||
title: "Rewind 2024",
|
||||
link: "/rewind",
|
||||
icon: <Rewind className="h-4 w-4" />,
|
||||
},
|
||||
title: "Geo Heatmap",
|
||||
link: "/assets/geo-heatmap",
|
||||
icon: <MapPin className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
@@ -5,7 +5,9 @@ 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
|
||||
VERSION: process.env.VERSION,
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY as string,
|
||||
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets";
|
||||
export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id;
|
||||
export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview";
|
||||
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
|
||||
export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap";
|
||||
|
||||
// Location
|
||||
|
||||
@@ -52,3 +53,5 @@ export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/peopl
|
||||
|
||||
// Rewind
|
||||
export const REWIND_STATS = BASE_API_ENDPOINT + "/rewind/stats";
|
||||
// Find
|
||||
export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search";
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface ConfigContextType {
|
||||
export interface ConfigContextType {
|
||||
immichURL: string;
|
||||
exImmichUrl: string;
|
||||
version?: string;
|
||||
geminiEnabled: boolean;
|
||||
googleMapsApiKey: string;
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType>({
|
||||
immichURL: "",
|
||||
exImmichUrl: "",
|
||||
version: "",
|
||||
geminiEnabled: false,
|
||||
});
|
||||
|
||||
export default ConfigContext;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
ADD_ASSETS_ALBUMS_PATH,
|
||||
ASSET_GEO_HEATMAP_PATH,
|
||||
FIND_ASSETS,
|
||||
LIST_ALBUMS_PATH,
|
||||
LIST_MISSING_LOCATION_ASSETS_PATH,
|
||||
LIST_MISSING_LOCATION_DATES_PATH,
|
||||
@@ -46,3 +48,11 @@ export const updateAssets = async (params: IUpdateAssetsParams) => {
|
||||
return API.put(UPDATE_ASSETS_PATH, params);
|
||||
}
|
||||
|
||||
|
||||
export const findAssets = async (query: string) => {
|
||||
return API.post(FIND_ASSETS, { query });
|
||||
}
|
||||
|
||||
export const getAssetGeoHeatmap = async () => {
|
||||
return API.get(ASSET_GEO_HEATMAP_PATH);
|
||||
}
|
||||
@@ -4,3 +4,17 @@ export const stringToBoolean = (value: string | boolean): boolean => {
|
||||
}
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
export const removeNullOrUndefinedProperties = (obj: any) => {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => {
|
||||
if (Array.isArray(v)) {
|
||||
return v.length > 0;
|
||||
}
|
||||
|
||||
if (typeof v === 'number') {
|
||||
return v !== null && v !== undefined && v !== 0;
|
||||
}
|
||||
|
||||
return v !== null && v !== undefined && v !== '' && v !== 'null' && v !== 'undefined' && v !== 'null' && v !== 'undefined'
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ENV } from "@/config/environment";
|
||||
import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai";
|
||||
import { z } from "zod";
|
||||
import { removeNullOrUndefinedProperties } from "./data.helper";
|
||||
|
||||
const gemini = new GoogleGenerativeAI(ENV.GEMINI_API_KEY);
|
||||
const model = gemini.getGenerativeModel({ model: "gemini-1.5-flash" });
|
||||
|
||||
interface FindQuery {
|
||||
query: string;
|
||||
personIds?: string[];
|
||||
city?: string;
|
||||
size?: number;
|
||||
model?: string;
|
||||
createdAfter?: string;
|
||||
state?: string;
|
||||
}
|
||||
const responseSchema = {
|
||||
type: SchemaType.OBJECT,
|
||||
properties: {
|
||||
query: { type: SchemaType.STRING, description: "Gist of the query. Remove the details of tags" },
|
||||
personIds: { type: SchemaType.ARRAY,
|
||||
description: "List of person ids that match the query which starts with @",
|
||||
items: { type: SchemaType.STRING }
|
||||
},
|
||||
city: { type: SchemaType.STRING,
|
||||
description: "City of the query"
|
||||
},
|
||||
size: {
|
||||
type: SchemaType.INTEGER,
|
||||
description: "Size of the file in bytes"
|
||||
},
|
||||
country: {
|
||||
type: SchemaType.STRING,
|
||||
description: "Any reference to a country (United States, Canada, etc) in the query, return full country name"
|
||||
},
|
||||
state: {
|
||||
type: SchemaType.STRING,
|
||||
description: "Any reference to a state (California, New York, etc) in the query"
|
||||
},
|
||||
takenAfter: {
|
||||
type: SchemaType.STRING,
|
||||
description: "Extract the date from the query and return it in the format YYYY-MM-DD but only if it is a valid date"
|
||||
},
|
||||
takenBefore: {
|
||||
type: SchemaType.STRING,
|
||||
description: "Extract the date from the query and return it in the format YYYY-MM-DD but only if it is a valid date"
|
||||
},
|
||||
model: {
|
||||
type: SchemaType.STRING,
|
||||
description: "Name of the device that the query is about"
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
};
|
||||
|
||||
export const parseFindQuery = async (query: string): Promise<FindQuery> => {
|
||||
|
||||
const prompt = `
|
||||
Parse the following query and return the query and tags: ${query}
|
||||
Additional Information For Parsing:
|
||||
today's date is ${new Date().toISOString().split('T')[0]}
|
||||
`;
|
||||
|
||||
const { response } = await model.generateContent({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema,
|
||||
}
|
||||
});
|
||||
|
||||
const parsedResponse = JSON.parse(response.text()) as FindQuery;
|
||||
const cleanedResponse = {
|
||||
...parsedResponse
|
||||
};
|
||||
return removeNullOrUndefinedProperties(cleanedResponse) as any as FindQuery;
|
||||
}
|
||||
+5
-7
@@ -3,14 +3,10 @@ import "@/styles/globals.scss";
|
||||
import type { AppProps } from "next/app";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ENV } from "@/config/environment";
|
||||
import ConfigContext from "@/contexts/ConfigContext";
|
||||
import ConfigContext, { ConfigContextType } from "@/contexts/ConfigContext";
|
||||
import { useRef } from "react";
|
||||
interface AppPropsWithProps extends AppProps {
|
||||
props: {
|
||||
immichURL: string;
|
||||
exImmichUrl: string;
|
||||
version?: string;
|
||||
};
|
||||
props: ConfigContextType;
|
||||
}
|
||||
const App = ({ Component, pageProps, ...props }: AppPropsWithProps) => {
|
||||
const intialData = useRef(props.props);
|
||||
@@ -32,7 +28,9 @@ App.getInitialProps = async () => {
|
||||
props: {
|
||||
exImmichUrl: ENV.EXTERNAL_IMMICH_URL,
|
||||
immichURL: ENV.IMMICH_URL,
|
||||
version: ENV.VERSION
|
||||
version: ENV.VERSION,
|
||||
geminiEnabled: !!ENV.GEMINI_API_KEY?.length,
|
||||
googleMapsApiKey: ENV.GOOGLE_MAPS_API_KEY,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { NextApiRequest } from "next";
|
||||
import { assets, exif } from "@/schema";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
if (!currentUser) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const dbAssets = await db.select({
|
||||
assetId: assets.id,
|
||||
latitude: exif.latitude,
|
||||
longitude: exif.longitude,
|
||||
}).from(assets)
|
||||
.innerJoin(exif, eq(assets.id, exif.assetId))
|
||||
.where(
|
||||
and(
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
isNotNull(exif.latitude),
|
||||
isNotNull(exif.longitude)
|
||||
)
|
||||
);
|
||||
const heatmapData = dbAssets.map((asset) => [
|
||||
asset.longitude,
|
||||
asset.latitude,
|
||||
]);
|
||||
res.status(200).json(heatmapData);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { db } from '@/config/db';
|
||||
import { ENV } from '@/config/environment';
|
||||
import { getCurrentUser } from '@/handlers/serverUtils/user.utils';
|
||||
import { parseFindQuery } from '@/helpers/gemini.helper';
|
||||
import { assetFaces, assets, person } from '@/schema';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function search(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { query } = req.body;
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const parsedQuery = await parseFindQuery(query as string);
|
||||
const url = ENV.IMMICH_URL + "/api/search/smart";
|
||||
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(parsedQuery),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${currentUser?.accessToken}`
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.status !== 200) {
|
||||
return res.json({
|
||||
assets: [],
|
||||
filters: parsedQuery,
|
||||
error: "Failed to fetch assets",
|
||||
errorData: response.json()
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
res.status(200).json({
|
||||
assets: data.assets.items,
|
||||
filters: parsedQuery
|
||||
});
|
||||
}).catch(err => {
|
||||
res.status(500).json({ error: err.message });
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Header from '@/components/shared/Header'
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useMap, useMapsLibrary } from '@vis.gl/react-google-maps';
|
||||
import { APIProvider, Map } from '@vis.gl/react-google-maps';
|
||||
import PageLayout from '@/components/layouts/PageLayout';
|
||||
import { getAssetGeoHeatmap } from '@/handlers/api/asset.handler';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { MapPinX } from 'lucide-react';
|
||||
|
||||
|
||||
const HeatMap = () => {
|
||||
|
||||
const map = useMap();
|
||||
const visualization = useMapsLibrary('visualization');
|
||||
|
||||
const heatmap = useMemo(() => {
|
||||
if (!visualization) return null;
|
||||
|
||||
return new google.maps.visualization.HeatmapLayer({
|
||||
radius: 20,
|
||||
opacity: 0.5
|
||||
});
|
||||
|
||||
}, [visualization]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!heatmap) return;
|
||||
|
||||
getAssetGeoHeatmap().then((data) => {
|
||||
console.log(data);
|
||||
heatmap.setData(data.map(([lng, lat]: [number, number]) => ({
|
||||
location: new google.maps.LatLng(lat, lng),
|
||||
weight: 10
|
||||
})));
|
||||
});
|
||||
|
||||
}, [heatmap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!heatmap) return;
|
||||
|
||||
heatmap.setMap(map);
|
||||
|
||||
return () => {
|
||||
heatmap.setMap(null);
|
||||
};
|
||||
}, [heatmap, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function GeoHeatmap() {
|
||||
const { googleMapsApiKey } = useConfig();
|
||||
const { theme } = useTheme();
|
||||
const [mapId, setMapId] = useState('7a9e2ebecd32a903');
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'light') {
|
||||
setMapId("");
|
||||
} else {
|
||||
setMapId('7a9e2ebecd32a903');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<Header leftComponent="Geo Heatmap" />
|
||||
<div className='h-full w-full'>
|
||||
{googleMapsApiKey ? (
|
||||
<APIProvider apiKey={googleMapsApiKey}>
|
||||
<Map
|
||||
defaultCenter={{ lat: 0, lng: 0 }}
|
||||
mapId={mapId}
|
||||
defaultZoom={2}
|
||||
gestureHandling={'greedy'}
|
||||
disableDefaultUI={true}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
|
||||
<HeatMap />
|
||||
</APIProvider>
|
||||
) : (
|
||||
<div className='h-full w-full flex items-center justify-center flex-col gap-2'>
|
||||
<MapPinX className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-muted-foreground'>No Google Maps API key found</p>
|
||||
<p className='text-muted-foreground'>
|
||||
Please create and add your Google Maps API key in the env file under <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>GOOGLE_MAPS_API_KEY</kbd>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import FindInput from '@/components/find/FindInput';
|
||||
import PageLayout from '@/components/layouts/PageLayout';
|
||||
import Header from '@/components/shared/Header';
|
||||
import Loader from '@/components/ui/loader';
|
||||
import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH } from '@/config/routes';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { findAssets } from '@/handlers/api/asset.handler';
|
||||
import { IAsset } from '@/types/asset';
|
||||
import { Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
|
||||
export default function FindPage() {
|
||||
|
||||
const [index, setIndex] = useState(-1);
|
||||
const { geminiEnabled, exImmichUrl } = useConfig();
|
||||
const [query, setQuery] = useState('');
|
||||
const [assets, setAssets] = useState<IAsset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const slides = useMemo(
|
||||
() =>
|
||||
assets.map((asset) => ({
|
||||
src: ASSET_PREVIEW_PATH(asset.id),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
})),
|
||||
[assets]
|
||||
);
|
||||
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setQuery(query);
|
||||
setLoading(true);
|
||||
findAssets(query)
|
||||
.then(({ assets }) => {
|
||||
setAssets(assets);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <div className="flex justify-center items-center h-full">
|
||||
<Loader />
|
||||
</div>
|
||||
}
|
||||
else if (query.length === 0) {
|
||||
return <div className="flex justify-center flex-col gap-2 items-center h-full">
|
||||
<Search className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-lg font-bold'>Search for photos in natural language</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Example: Sunset photos from last week. Use <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>@</kbd> to search for photos of a specific person.
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground text-center flex gap-1 items-center'>
|
||||
<span>Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini.</span>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (assets.length === 0) {
|
||||
return <div className="flex justify-center items-center h-full">
|
||||
No results found
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Lightbox
|
||||
slides={slides}
|
||||
open={index >= 0}
|
||||
index={index}
|
||||
close={() => setIndex(-1)}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-4 p-2">
|
||||
{assets.map((asset, idx) => (
|
||||
<div key={asset.id} className='w-full h-[200px] rounded-md overflow-hidden relative'>
|
||||
<Link href={`${exImmichUrl}/photos/${asset.id}`}
|
||||
target="_blank"
|
||||
className='absolute top-2 right-2 text-xs'>
|
||||
Open in Immich
|
||||
</Link>
|
||||
<Image
|
||||
src={ASSET_THUMBNAIL_PATH(asset.id)}
|
||||
alt={asset.id}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
width={200}
|
||||
height={200}
|
||||
onClick={() => setIndex(idx)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<Header leftComponent="Find" />
|
||||
{geminiEnabled ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 p-2">
|
||||
<FindInput onSearch={handleSearch} />
|
||||
</div>
|
||||
{renderContent()}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-center py-10 items-center h-full flex-col gap-2">
|
||||
<WandSparkles className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-lg font-semibold'>Google Gemini is not enabled</p>
|
||||
<p className='text-sm text-muted-foreground max-w-md text-center'>
|
||||
Currently, the Power Tools Find is relying on Google Gemini for parsing the query.
|
||||
Please enable Gemini by adding <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>GEMINI_API_KEY</kbd> in the <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>.env</kbd> file.
|
||||
</p>
|
||||
<div className="border border-l-4 border-zinc-200 dark:border-zinc-500 rounded-md p-2">
|
||||
<p className='text-xs text-muted-foreground text-center flex gap-1 items-center'>
|
||||
<span>Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,6 @@
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -88,3 +87,4 @@
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user