feat: added people detail screen
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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 ">
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />,
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user