diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx index 7ef8b6d..a4fb7a1 100644 --- a/src/components/albums/list/AlbumThumbnail.tsx +++ b/src/components/albums/list/AlbumThumbnail.tsx @@ -40,7 +40,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
diff --git a/src/components/layouts/PageLayout.tsx b/src/components/layouts/PageLayout.tsx index cc64db2..fb22e9c 100644 --- a/src/components/layouts/PageLayout.tsx +++ b/src/components/layouts/PageLayout.tsx @@ -1,12 +1,15 @@ import React from "react"; import Header from "../shared/Header"; import { cn } from "@/lib/utils"; +import Head from "next/head"; + interface IProps { children: React.ReactNode[] | React.ReactNode; className?: string; + title?: string; } -export default function PageLayout({ children, className }: IProps) { +export default function PageLayout({ children, className, title }: IProps) { const header = Array.isArray(children) ? children.find( (child) => React.isValidElement(child) && child.type === Header @@ -24,7 +27,7 @@ export default function PageLayout({ children, className }: IProps) { {header}
{person.assetCount} Assets
-
+
+ + +
diff --git a/src/components/people/info/PersonCityList.tsx b/src/components/people/info/PersonCityList.tsx new file mode 100644 index 0000000..baa043a --- /dev/null +++ b/src/components/people/info/PersonCityList.tsx @@ -0,0 +1,61 @@ +import { useConfig } from '@/contexts/ConfigContext'; +import { ExternalLink } from 'lucide-react'; +import Link from 'next/link'; +import React, { useMemo } from 'react' + +interface PersonCityListProps { + cities: { + city: string; + country: string; + count: number; + }[] + personId: string; +} + +export const PersonCity = ({ city, count, personId }: { city: string, count: number, personId: string }) => { + const { exImmichUrl } = useConfig(); + const url = `${exImmichUrl}/search?query=${JSON.stringify({ city, personIds: [personId] })}`; + return ( +
+ {city} + {count} occurrences + + + +
+ ) +} +export default function PersonCityList({ cities, personId }: PersonCityListProps) { + + const groupedCities = useMemo(() => { + const countries: { + label: string; + cities: { + city: string; + count: number; + }[]; + }[] = []; + const uniqueCountries = cities.map((city) => city.country).filter((country, index, self) => self.indexOf(country) === index); + uniqueCountries.forEach((country) => { + countries.push({ label: country, cities: cities.filter((city) => city.country === country) }); + }); + return countries; + }, [cities]); + + return ( +
+ {groupedCities.map((country) => ( +
+ {/* Country */} + {country.label} + {/* Cities */} +
+ {country.cities.map((city) => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/src/components/shared/Header.tsx b/src/components/shared/Header.tsx index c876f0e..99cdf10 100644 --- a/src/components/shared/Header.tsx +++ b/src/components/shared/Header.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import Link from "next/link"; import { Home, @@ -27,9 +27,20 @@ import { useRouter } from "next/router"; interface IProps { leftComponent?: React.ReactNode | string; rightComponent?: React.ReactNode | string; + title?: string; } -export default function Header({ leftComponent, rightComponent }: IProps) { +export default function Header({ leftComponent, rightComponent, title }: IProps) { const { pathname } = useRouter(); + const pageTitle = useMemo(() => { + if (title && typeof title === "string") { + return title; + } + if (typeof leftComponent === "string") { + return leftComponent; + } + return ""; + }, [title, leftComponent]); + const renderLeftComponent = () => { if (typeof leftComponent === "string") { return

{leftComponent}

; @@ -46,9 +57,9 @@ export default function Header({ leftComponent, rightComponent }: IProps) { return ( <> - {typeof leftComponent === "string" && ( + {!!pageTitle && ( - {leftComponent} - Immich Power Tools + {pageTitle} - Immich Power Tools )}
diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index 7e0a839..7f1eebe 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -7,7 +7,7 @@ export const sidebarNavs = [ icon: , }, { - title: "Exif Analytics", + title: "Analytics", link: "/analytics/exif", icon: , }, diff --git a/src/config/routes.ts b/src/config/routes.ts index dbd2949..090483c 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -46,3 +46,6 @@ export const LIVE_PHOTO_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/ export const HEATMAP_DATA = BASE_API_ENDPOINT + "/analytics/statistics/heatmap"; // Common export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters"; + +// Person +export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/people/" + personId + "/info"; \ No newline at end of file diff --git a/src/handlers/api/person.handler.ts b/src/handlers/api/person.handler.ts new file mode 100644 index 0000000..fcd0cc4 --- /dev/null +++ b/src/handlers/api/person.handler.ts @@ -0,0 +1,6 @@ +import API from "@/lib/api"; +import { GET_PERSON_INFO } from "@/config/routes"; + +export const getPersonInfo = async (personId: string) => { + return API.get(GET_PERSON_INFO(personId)); +} \ No newline at end of file diff --git a/src/pages/analytics/exif.tsx b/src/pages/analytics/exif.tsx index 7e6a4f7..59244ef 100644 --- a/src/pages/analytics/exif.tsx +++ b/src/pages/analytics/exif.tsx @@ -106,7 +106,7 @@ export default function ExifDataAnalytics() { }, []); return ( - +
{["Total", "Images", "Videos"].map((type, i) => ( diff --git a/src/pages/api/people/[id]/info.ts b/src/pages/api/people/[id]/info.ts new file mode 100644 index 0000000..31c07d2 --- /dev/null +++ b/src/pages/api/people/[id]/info.ts @@ -0,0 +1,63 @@ +import { NextApiResponse } from "next"; + +import { and, desc, eq, isNotNull, sql } from "drizzle-orm"; + +import { db } from "@/config/db"; +import { person } from "@/schema/person.schema"; +import { NextApiRequest } from "next"; +import { albums } from "@/schema/albums.schema"; +import { assetFaces, assets, exif } from "@/schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { id } = req.query; + const personRecords = await db + .select() + .from(person) + .where(eq(person.id, id as string)) + .limit(1); + + const personRecord = personRecords?.[0]; + if (!personRecord) { + return res.status(404).json({ + error: "Person not found", + }); + } + + const dbPersonAlbums = await db + .select({ + id: albums.id, + name: albums.albumName, + thumbnail: albums.albumThumbnailAssetId, + assetCount: sql`count(${assets.id})`, + }) + .from(albums) + .leftJoin(assetFaces, eq(assetFaces.personId, personRecord.id)) + .leftJoin(assets, eq(assets.id, assetFaces.assetId)) + .leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id)) + .where(eq(albumsAssetsAssets.albumsId, albums.id)) + .groupBy(albums.id); + + + const dbPersonCities = await db.select({ + city: exif.city, + country: exif.country, + count: sql`count(${exif.assetId})`, + }).from(exif) + .leftJoin(assets, eq(assets.id, exif.assetId)) + .leftJoin(assetFaces, eq(assetFaces.assetId, assets.id)) + .where(and( + eq(assetFaces.personId, personRecord.id), + isNotNull(exif.city), + isNotNull(exif.country), + )) + .groupBy(exif.city, exif.country) + .orderBy(desc(exif.city)) + + + return res.status(200).json({ + ...personRecord, + albums: dbPersonAlbums.sort((a, b) => b.assetCount - a.assetCount), + cities: dbPersonCities, + }); +} diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index 6921532..7a3b30e 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -1,12 +1,8 @@ -import AlbumSelectorDialog from "@/components/albums/AlbumSelectorDialog"; -import PotentialAlbumsAssets from "@/components/albums/potential-albums/PotentialAlbumsAssets"; -import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates"; import AssetsOptions from "@/components/assets/assets-options/AssetsOptions"; import MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets"; 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"; diff --git a/src/pages/people/[personId].tsx b/src/pages/people/[personId].tsx new file mode 100644 index 0000000..9811e25 --- /dev/null +++ b/src/pages/people/[personId].tsx @@ -0,0 +1,143 @@ +import PageLayout from '@/components/layouts/PageLayout'; +import PersonCityList from '@/components/people/info/PersonCityList'; +import Header from '@/components/shared/Header'; +import { Button } from '@/components/ui/button'; +import Loader from '@/components/ui/loader'; +import { ASSET_THUMBNAIL_PATH, PERSON_THUBNAIL_PATH } from '@/config/routes'; +import { useConfig } from '@/contexts/ConfigContext'; +import { getPersonInfo } from '@/handlers/api/person.handler'; +import { IAlbum } from '@/types/album'; +import { IPerson } from '@/types/person' +import { GalleryThumbnails, MapPin, Plus } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react' + +interface IPersonFullInfo extends IPerson { + albums: { + id: string; + name: string; + assetCount: number; + thumbnail: string; + }[]; + cities: { + city: string; + country: string; + count: number; + }[]; +} + +export default function PersonPage() { + const { exImmichUrl } = useConfig(); + const router = useRouter(); + const { pathname, query } = router; + const { personId } = router.query as { personId: string }; + const [loading, setLoading] = useState(true); + const [person, setPerson] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + + const fetchPersonInfo = async (personId: string) => { + setLoading(true); + getPersonInfo(personId).then(setPerson).catch((err) => { + setErrorMessage(err.message); + + }).finally(() => { + setLoading(false); + }); + } + + useEffect(() => { + fetchPersonInfo(personId); + }, [personId]); + + const renderContent = () => { + if (query.tab === "cities") { + return ( +
+
+ Cities +
+ +
+ ) + } + + return ( +
+
+ Albums {person?.albums.length} +
+
+ {person?.albums.map((album) => ( +
+
+ {album.name} +
+ {album.name} + {album.assetCount} Occurences +
+ ))} +
+
+ ) + } + + if (loading) return + if (errorMessage) return
{errorMessage}
; + if (!person) return
Person not found
; + + + return ( + +
+ {person?.name} + {person?.name} +
+ } + /> +
+
+
    +
  • + + + Albums + +
  • +
  • + + + Cities + +
  • +
+
+
+ {renderContent()} +
+
+ + ) +}