feat: added people detail screen

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2024-11-30 19:24:34 +05:30
parent 9139e82107
commit e0dd1b9e00
12 changed files with 307 additions and 15 deletions
@@ -40,7 +40,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
</div>
<Checkbox
onCheckedChange={onSelect}
className="absolute hidden top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="absolute top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
/>
</label>
<div className="items-center gap-2 absolute top-2 right-2 group-hover:hidden flex ">
+5 -2
View File
@@ -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}
<main
className={cn(
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 p-4 lg:gap-6 lg:p-6 overflow-y-auto mx-auto",
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 overflow-y-auto mx-auto",
{
"mb-14": !!header,
},
+8 -2
View File
@@ -8,7 +8,7 @@ import PersonHideCell from "./PersonHideCell";
import clsx from "clsx";
import Link from "next/link";
import { ENV } from "@/config/environment";
import { ArrowUpRight } from "lucide-react";
import { ArrowUpRight, Search } from "lucide-react";
import { useConfig } from "@/contexts/ConfigContext";
import { useToast } from "../ui/use-toast";
import { Badge } from "../ui/badge";
@@ -95,7 +95,7 @@ export default function PersonItem({ person, onRemove }: IProps) {
<div className="absolute bottom-2 w-full flex justify-center items-center">
<Badge variant={"secondary"} className="text-xs !font-medium font-mono">{person.assetCount} Assets</Badge>
</div>
<div className="absolute top-2 left-2 group-hover:block hidden">
<div className="absolute top-2 left-2 group-hover:flex hidden items-center gap-2">
<Link
className="bg-green-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
href={`${exImmichUrl}/people/${person.id}`}
@@ -103,6 +103,12 @@ export default function PersonItem({ person, onRemove }: IProps) {
>
<ArrowUpRight size={16} />
</Link>
<Link
className="bg-gray-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
href={`/people/${person.id}`}
>
<Search size={16} />
</Link>
</div>
<div className="absolute top-2 right-2 ">
<PersonMergeDropdown person={person} onRemove={onRemove}/>
@@ -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 (
<div className="flex flex-col gap-2 p-2 w-full bg-gray-100 dark:bg-zinc-800 rounded-md shadow border relative">
<span className="text-sm truncate">{city}</span>
<span className="text-sm truncate text-gray-500 dark:text-gray-400">{count} occurrences</span>
<Link href={url} target="_blank" className="absolute top-2 right-2" >
<ExternalLink className=" w-4 h-4" />
</Link>
</div>
)
}
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 (
<div className='flex flex-col gap-4'>
{groupedCities.map((country) => (
<div key={country.label} className="flex flex-col gap-2 w-full">
{/* Country */}
<span className="font-medium">{country.label}</span>
{/* Cities */}
<div className='grid grid-cols-2 gap-2 md:grid-cols-5 w-full'>
{country.cities.map((city) => (
<PersonCity key={city.city} city={city.city} count={city.count} personId={personId} />
))}
</div>
</div>
))}
</div>
)
}
+15 -4
View File
@@ -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 <p className="font-semibold">{leftComponent}</p>;
@@ -46,9 +57,9 @@ export default function Header({ leftComponent, rightComponent }: IProps) {
return (
<>
{typeof leftComponent === "string" && (
{!!pageTitle && (
<Head>
<title>{leftComponent} - Immich Power Tools</title>
<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">
+1 -1
View File
@@ -7,7 +7,7 @@ export const sidebarNavs = [
icon: <User className="h-4 w-4" />,
},
{
title: "Exif Analytics",
title: "Analytics",
link: "/analytics/exif",
icon: <Image className="h-4 w-4" />,
},
+3
View File
@@ -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";
+6
View File
@@ -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));
}
+1 -1
View File
@@ -106,7 +106,7 @@ export default function ExifDataAnalytics() {
}, []);
return (
<PageLayout>
<PageLayout className="p-4">
<Header leftComponent="Exif Data" />
<div className="grid grid-cols-4 gap-4">
{["Total", "Images", "Videos"].map((type, i) => (
+63
View File
@@ -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<number>`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<number>`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,
});
}
-4
View File
@@ -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";
+143
View File
@@ -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<IPersonFullInfo | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-lg font-medium">Cities</span>
</div>
<PersonCityList cities={person?.cities || []} personId={personId} />
</div>
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Albums {person?.albums.length}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 w-full">
{person?.albums.map((album) => (
<div key={album.id} className="flex flex-col gap-2 w-full">
<div className="relative w-full h-[200px]">
<Image src={ASSET_THUMBNAIL_PATH(album.thumbnail)}
alt={album.name}
width={100}
height={100}
className="rounded-md w-full h-full object-cover" />
</div>
<Link href={`${exImmichUrl}/albums/${album.id}`} target="_blank" className="text-sm font-medium truncate">{album.name}</Link>
<span className="text-sm text-gray-500 truncate">{album.assetCount} Occurences</span>
</div>
))}
</div>
</div>
)
}
if (loading) return <Loader />
if (errorMessage) return <div>{errorMessage}</div>;
if (!person) return <div>Person not found</div>;
return (
<PageLayout className="!p-0 !mb-0" title={person?.name}>
<Header
title={person?.name}
leftComponent={
<div className="flex items-center gap-2">
<Image src={PERSON_THUBNAIL_PATH(person?.id)}
alt={person?.name} width={32} height={32} className="rounded-full" />
<span className="font-medium">{person?.name}</span>
</div>
}
/>
<div className="flex divide-y">
<div className="overflow-y-auto sticky top-0 min-w-[200px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
<ul className="flex flex-col gap-1">
<li>
<Link href={{
pathname: pathname,
query: {
...query,
tab: "albums"
}
}} className="flex items-center gap-2 py-2 px-2 rounded-md hover:bg-gray-300 dark:hover:bg-zinc-800">
<GalleryThumbnails size={14} />
<span className="text-sm font-medium">Albums</span>
</Link>
</li>
<li>
<Link href={{
pathname: pathname,
query: {
...query,
tab: "cities"
}
}} className="flex items-center gap-2 py-2 px-2 rounded-md hover:bg-gray-300 dark:hover:bg-zinc-800">
<MapPin size={14} />
<span className="text-sm font-medium">Cities</span>
</Link>
</li>
</ul>
</div>
<div className="p-4 flex-1">
{renderContent()}
</div>
</div>
</PageLayout>
)
}