Skip to content

Commit e0dd1b9

Browse files
committed
feat: added people detail screen
Signed-off-by: Varun Raj <varun@skcript.com>
1 parent 9139e82 commit e0dd1b9

File tree

12 files changed

+307
-15
lines changed

12 files changed

+307
-15
lines changed

src/components/albums/list/AlbumThumbnail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
4040
</div>
4141
<Checkbox
4242
onCheckedChange={onSelect}
43-
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"
43+
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"
4444
/>
4545
</label>
4646
<div className="items-center gap-2 absolute top-2 right-2 group-hover:hidden flex ">

src/components/layouts/PageLayout.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import React from "react";
22
import Header from "../shared/Header";
33
import { cn } from "@/lib/utils";
4+
import Head from "next/head";
5+
46

57
interface IProps {
68
children: React.ReactNode[] | React.ReactNode;
79
className?: string;
10+
title?: string;
811
}
9-
export default function PageLayout({ children, className }: IProps) {
12+
export default function PageLayout({ children, className, title }: IProps) {
1013
const header = Array.isArray(children)
1114
? children.find(
1215
(child) => React.isValidElement(child) && child.type === Header
@@ -24,7 +27,7 @@ export default function PageLayout({ children, className }: IProps) {
2427
{header}
2528
<main
2629
className={cn(
27-
"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",
30+
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 overflow-y-auto mx-auto",
2831
{
2932
"mb-14": !!header,
3033
},

src/components/people/PersonItem.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import PersonHideCell from "./PersonHideCell";
88
import clsx from "clsx";
99
import Link from "next/link";
1010
import { ENV } from "@/config/environment";
11-
import { ArrowUpRight } from "lucide-react";
11+
import { ArrowUpRight, Search } from "lucide-react";
1212
import { useConfig } from "@/contexts/ConfigContext";
1313
import { useToast } from "../ui/use-toast";
1414
import { Badge } from "../ui/badge";
@@ -95,14 +95,20 @@ export default function PersonItem({ person, onRemove }: IProps) {
9595
<div className="absolute bottom-2 w-full flex justify-center items-center">
9696
<Badge variant={"secondary"} className="text-xs !font-medium font-mono">{person.assetCount} Assets</Badge>
9797
</div>
98-
<div className="absolute top-2 left-2 group-hover:block hidden">
98+
<div className="absolute top-2 left-2 group-hover:flex hidden items-center gap-2">
9999
<Link
100100
className="bg-green-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
101101
href={`${exImmichUrl}/people/${person.id}`}
102102
target="_blank"
103103
>
104104
<ArrowUpRight size={16} />
105105
</Link>
106+
<Link
107+
className="bg-gray-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
108+
href={`/people/${person.id}`}
109+
>
110+
<Search size={16} />
111+
</Link>
106112
</div>
107113
<div className="absolute top-2 right-2 ">
108114
<PersonMergeDropdown person={person} onRemove={onRemove}/>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useConfig } from '@/contexts/ConfigContext';
2+
import { ExternalLink } from 'lucide-react';
3+
import Link from 'next/link';
4+
import React, { useMemo } from 'react'
5+
6+
interface PersonCityListProps {
7+
cities: {
8+
city: string;
9+
country: string;
10+
count: number;
11+
}[]
12+
personId: string;
13+
}
14+
15+
export const PersonCity = ({ city, count, personId }: { city: string, count: number, personId: string }) => {
16+
const { exImmichUrl } = useConfig();
17+
const url = `${exImmichUrl}/search?query=${JSON.stringify({ city, personIds: [personId] })}`;
18+
return (
19+
<div className="flex flex-col gap-2 p-2 w-full bg-gray-100 dark:bg-zinc-800 rounded-md shadow border relative">
20+
<span className="text-sm truncate">{city}</span>
21+
<span className="text-sm truncate text-gray-500 dark:text-gray-400">{count} occurrences</span>
22+
<Link href={url} target="_blank" className="absolute top-2 right-2" >
23+
<ExternalLink className=" w-4 h-4" />
24+
</Link>
25+
</div>
26+
)
27+
}
28+
export default function PersonCityList({ cities, personId }: PersonCityListProps) {
29+
30+
const groupedCities = useMemo(() => {
31+
const countries: {
32+
label: string;
33+
cities: {
34+
city: string;
35+
count: number;
36+
}[];
37+
}[] = [];
38+
const uniqueCountries = cities.map((city) => city.country).filter((country, index, self) => self.indexOf(country) === index);
39+
uniqueCountries.forEach((country) => {
40+
countries.push({ label: country, cities: cities.filter((city) => city.country === country) });
41+
});
42+
return countries;
43+
}, [cities]);
44+
45+
return (
46+
<div className='flex flex-col gap-4'>
47+
{groupedCities.map((country) => (
48+
<div key={country.label} className="flex flex-col gap-2 w-full">
49+
{/* Country */}
50+
<span className="font-medium">{country.label}</span>
51+
{/* Cities */}
52+
<div className='grid grid-cols-2 gap-2 md:grid-cols-5 w-full'>
53+
{country.cities.map((city) => (
54+
<PersonCity key={city.city} city={city.city} count={city.count} personId={personId} />
55+
))}
56+
</div>
57+
</div>
58+
))}
59+
</div>
60+
)
61+
}

src/components/shared/Header.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useMemo } from "react";
22
import Link from "next/link";
33
import {
44
Home,
@@ -27,9 +27,20 @@ import { useRouter } from "next/router";
2727
interface IProps {
2828
leftComponent?: React.ReactNode | string;
2929
rightComponent?: React.ReactNode | string;
30+
title?: string;
3031
}
31-
export default function Header({ leftComponent, rightComponent }: IProps) {
32+
export default function Header({ leftComponent, rightComponent, title }: IProps) {
3233
const { pathname } = useRouter();
34+
const pageTitle = useMemo(() => {
35+
if (title && typeof title === "string") {
36+
return title;
37+
}
38+
if (typeof leftComponent === "string") {
39+
return leftComponent;
40+
}
41+
return "";
42+
}, [title, leftComponent]);
43+
3344
const renderLeftComponent = () => {
3445
if (typeof leftComponent === "string") {
3546
return <p className="font-semibold">{leftComponent}</p>;
@@ -46,9 +57,9 @@ export default function Header({ leftComponent, rightComponent }: IProps) {
4657

4758
return (
4859
<>
49-
{typeof leftComponent === "string" && (
60+
{!!pageTitle && (
5061
<Head>
51-
<title>{leftComponent} - Immich Power Tools</title>
62+
<title>{pageTitle} - Immich Power Tools</title>
5263
</Head>
5364
)}
5465
<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">

src/config/constants/sidebarNavs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const sidebarNavs = [
77
icon: <User className="h-4 w-4" />,
88
},
99
{
10-
title: "Exif Analytics",
10+
title: "Analytics",
1111
link: "/analytics/exif",
1212
icon: <Image className="h-4 w-4" />,
1313
},

src/config/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ export const LIVE_PHOTO_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/
4646
export const HEATMAP_DATA = BASE_API_ENDPOINT + "/analytics/statistics/heatmap";
4747
// Common
4848
export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
49+
50+
// Person
51+
export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/people/" + personId + "/info";

src/handlers/api/person.handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import API from "@/lib/api";
2+
import { GET_PERSON_INFO } from "@/config/routes";
3+
4+
export const getPersonInfo = async (personId: string) => {
5+
return API.get(GET_PERSON_INFO(personId));
6+
}

src/pages/analytics/exif.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export default function ExifDataAnalytics() {
106106
}, []);
107107

108108
return (
109-
<PageLayout>
109+
<PageLayout className="p-4">
110110
<Header leftComponent="Exif Data" />
111111
<div className="grid grid-cols-4 gap-4">
112112
{["Total", "Images", "Videos"].map((type, i) => (

src/pages/api/people/[id]/info.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { NextApiResponse } from "next";
2+
3+
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
4+
5+
import { db } from "@/config/db";
6+
import { person } from "@/schema/person.schema";
7+
import { NextApiRequest } from "next";
8+
import { albums } from "@/schema/albums.schema";
9+
import { assetFaces, assets, exif } from "@/schema";
10+
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
11+
12+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
13+
const { id } = req.query;
14+
const personRecords = await db
15+
.select()
16+
.from(person)
17+
.where(eq(person.id, id as string))
18+
.limit(1);
19+
20+
const personRecord = personRecords?.[0];
21+
if (!personRecord) {
22+
return res.status(404).json({
23+
error: "Person not found",
24+
});
25+
}
26+
27+
const dbPersonAlbums = await db
28+
.select({
29+
id: albums.id,
30+
name: albums.albumName,
31+
thumbnail: albums.albumThumbnailAssetId,
32+
assetCount: sql<number>`count(${assets.id})`,
33+
})
34+
.from(albums)
35+
.leftJoin(assetFaces, eq(assetFaces.personId, personRecord.id))
36+
.leftJoin(assets, eq(assets.id, assetFaces.assetId))
37+
.leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id))
38+
.where(eq(albumsAssetsAssets.albumsId, albums.id))
39+
.groupBy(albums.id);
40+
41+
42+
const dbPersonCities = await db.select({
43+
city: exif.city,
44+
country: exif.country,
45+
count: sql<number>`count(${exif.assetId})`,
46+
}).from(exif)
47+
.leftJoin(assets, eq(assets.id, exif.assetId))
48+
.leftJoin(assetFaces, eq(assetFaces.assetId, assets.id))
49+
.where(and(
50+
eq(assetFaces.personId, personRecord.id),
51+
isNotNull(exif.city),
52+
isNotNull(exif.country),
53+
))
54+
.groupBy(exif.city, exif.country)
55+
.orderBy(desc(exif.city))
56+
57+
58+
return res.status(200).json({
59+
...personRecord,
60+
albums: dbPersonAlbums.sort((a, b) => b.assetCount - a.assetCount),
61+
cities: dbPersonCities,
62+
});
63+
}

0 commit comments

Comments
 (0)