diff --git a/package.json b/package.json index 6cafea77..ea9d8632 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test": "jest --watch", "test:ci": "jest --ci", "test:e2e": "npx playwright test", - "typecheck": "tsc", + "typecheck": "echo 'Typecheck...' && tsc", "release": "standard-version" }, "dependencies": { diff --git a/pages/[gallery].tsx b/pages/[gallery].tsx index f8068684..6674b197 100644 --- a/pages/[gallery].tsx +++ b/pages/[gallery].tsx @@ -1,27 +1,13 @@ import type { GetStaticPaths, GetStaticProps } from 'next' -import Head from 'next/head' -import { type ParsedUrlQuery } from 'node:querystring' +import GalleryPageComponent from '../src/components/GalleryPage' import getAlbums from '../src/lib/albums' import getGalleries from '../src/lib/galleries' import indexKeywords from '../src/lib/search' +import type { ServerSideAlbumItem } from '../src/types/common' +import { Gallery } from '../src/types/pages' -import Galleries from '../src/components/Albums' -import Link from '../src/components/Link' -import useSearch from '../src/hooks/useSearch' -import type { AlbumMeta, IndexedKeywords, ServerSideAlbumItem } from '../src/types/common' - -type ComponentProps = { - gallery: NonNullable; - albums: ServerSideAlbumItem[]; - indexedKeywords: IndexedKeywords[]; -} - -interface Params extends ParsedUrlQuery { - gallery: NonNullable -} - -export const getStaticProps: GetStaticProps = async (context) => { +export const getStaticProps: GetStaticProps = async (context) => { const params = context.params! const { albums } = await getAlbums(params.gallery) const preparedAlbums = albums.map((album): ServerSideAlbumItem => ({ @@ -43,25 +29,10 @@ export const getStaticPaths: GetStaticPaths = async () => { } } -function AlbumsPage({ gallery, albums, indexedKeywords }: ComponentProps) { - const { - filtered, - searchBox, - } = useSearch({ items: albums, indexedKeywords }) - +function GalleryPage({ gallery, albums, indexedKeywords }: Gallery.ComponentProps) { return ( -
- - History App - List Albums - - -
{searchBox}
-

Links

-
  • All
-
  • Today
- -
+ ) } -export default AlbumsPage +export default GalleryPage diff --git a/pages/[gallery]/[album].tsx b/pages/[gallery]/[album].tsx index 753eedde..0832f1f5 100644 --- a/pages/[gallery]/[album].tsx +++ b/pages/[gallery]/[album].tsx @@ -6,7 +6,7 @@ import getAlbum from '../../src/lib/album' import getAlbums from '../../src/lib/albums' import getGalleries from '../../src/lib/galleries' import indexKeywords, { addGeographyToSearch } from '../../src/lib/search' -import type { AlbumMeta, IndexedKeywords, Item } from '../../src/types/common' +import type { Album } from '../../src/types/pages' async function buildStaticPaths() { const { galleries } = await getGalleries() @@ -17,22 +17,7 @@ async function buildStaticPaths() { return groups.flat() } -interface ServerSideAlbumItem extends Item { - corpus: string; -} - -type ComponentProps = { - items?: ServerSideAlbumItem[]; - meta: AlbumMeta; - indexedKeywords: IndexedKeywords[]; -} - -interface Params extends ParsedUrlQuery { - gallery: NonNullable - album: NonNullable -} - -export const getStaticProps: GetStaticProps = async (context) => { +export const getStaticProps: GetStaticProps = async (context) => { const params = context.params! const { album: { items, meta } } = await getAlbum(params.gallery, params.album) const preparedItems = items.map((item) => ({ @@ -54,7 +39,7 @@ export const getStaticPaths: GetStaticPaths = async () => ( } ) -function AlbumPage({ items = [], meta, indexedKeywords }: ComponentProps) { +function AlbumPage({ items = [], meta, indexedKeywords }: Album.ComponentProps) { return } diff --git a/pages/[gallery]/[album]/nearby.tsx b/pages/[gallery]/[album]/nearby.tsx deleted file mode 100644 index ce0fd65e..00000000 --- a/pages/[gallery]/[album]/nearby.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Head from 'next/head' - -function Nearby() { - return ( - <> - - History App - Nearby - - -

Hello World

- - ) -} - -export default Nearby diff --git a/pages/[gallery]/all.tsx b/pages/[gallery]/all.tsx index 3ab4b0f6..dea64ad9 100644 --- a/pages/[gallery]/all.tsx +++ b/pages/[gallery]/all.tsx @@ -1,7 +1,9 @@ import type { GetStaticPaths, GetStaticProps } from 'next' import Head from 'next/head' import { type ParsedUrlQuery } from 'node:querystring' -import { useMemo, useRef, useState } from 'react' +import { + useEffect, useMemo, useRef, useState, +} from 'react' import type ReactImageGallery from 'react-image-gallery' import config from '../../config.json' @@ -9,26 +11,262 @@ import getAlbum from '../../src/lib/album' import getAlbums from '../../src/lib/albums' import getGalleries from '../../src/lib/galleries' import indexKeywords, { addGeographyToSearch } from '../../src/lib/search' - import All from '../../src/components/All' import AlbumContext from '../../src/components/Context' import SplitViewer from '../../src/components/SplitViewer' import useMemory from '../../src/hooks/useMemory' import useSearch from '../../src/hooks/useSearch' - import { - AlbumMeta, IndexedKeywords, Item, ServerSideAllItem, + AlbumMeta, + IndexedKeywords, + Item, + ServerSideAllItem, } from '../../src/types/common' type ComponentProps = { - items: ServerSideAllItem[]; - indexedKeywords: IndexedKeywords[]; + items: ServerSideAllItem[] + indexedKeywords: IndexedKeywords[] } interface Params extends ParsedUrlQuery { gallery: NonNullable } +function calculateAge(dob: string, photoDate: string): number | null { + try { + const birth = new Date(dob.substring(0, 10)) + const photo = new Date(photoDate.substring(0, 10)) + + // Validate dates + if (Number.isNaN(birth.getTime()) || Number.isNaN(photo.getTime())) { + return null + } + + let age = photo.getFullYear() - birth.getFullYear() + const m = photo.getMonth() - birth.getMonth() + if (m < 0 || (m === 0 && photo.getDate() < birth.getDate())) { + age -= 1 + } + return age + } catch (e) { + return null + } +} + +type PersonMatch = { + name: string + age: number + photoDate: string +} + +function AllPage({ items = [], indexedKeywords }: ComponentProps) { + const refImageGallery = useRef(null) + const [memoryIndex, setMemoryIndex] = useState(0) + const [selectedAge, setSelectedAge] = useState(null) + const [selectedPerson, setSelectedPerson] = useState(null) + const [uniqueAges, setUniqueAges] = useState([]) + + const { + filtered: keywordFiltered, + keyword, + searchBox, + setFiltered, + } = useSearch({ items, setMemoryIndex, indexedKeywords }) + + const [ageFiltered, setAgeFiltered] = useState(keywordFiltered) + + // Update age filtered results whenever keyword search or age/person selection changes + useEffect(() => { + if (selectedAge === null) { + setAgeFiltered(keywordFiltered) + return + } + + const filtered = keywordFiltered.filter((item) => { + if (!item.persons || !item.filename) return false + const photoDate = Array.isArray(item.filename) + ? item.filename[0].substring(0, 10) + : item.filename.substring(0, 10) + return item.persons.some((person) => { + if (!person.dob) return false + const matchesAge = calculateAge(person.dob, photoDate) === selectedAge + const matchesPerson = selectedPerson ? person.full === selectedPerson : true + return matchesAge && matchesPerson + }) + }) + + setAgeFiltered(filtered) + }, [keywordFiltered, selectedAge, selectedPerson]) + + // Update the search results with our age-filtered items + useEffect(() => { + setFiltered(ageFiltered) + }, [ageFiltered, setFiltered]) + + // Update uniqueAges whenever keyword search changes + useEffect(() => { + const ages = new Set( + keywordFiltered.flatMap((item) => item.persons?.map((person) => { + if (!person.dob || !item.filename) return null + const photoDate = Array.isArray(item.filename) + ? item.filename[0].substring(0, 10) + : item.filename.substring(0, 10) + const age = calculateAge(person.dob, photoDate) + return age + })).filter((age): age is number => age !== null && !Number.isNaN(age)), + ) + setUniqueAges(Array.from(ages).sort((a, b) => a - b)) + + if (selectedAge !== null && !ages.has(selectedAge)) { + setSelectedAge(null) + setSelectedPerson(null) + } + }, [keywordFiltered, selectedAge, keyword]) + + const agesWithCounts = useMemo(() => { + const counts = new Map() + + keywordFiltered.forEach((item) => { + if (!item.persons || !item.filename) return + const photoDate = Array.isArray(item.filename) + ? item.filename[0].substring(0, 10) + : item.filename.substring(0, 10) + + const agesCounted = new Set() + + item.persons.forEach((person) => { + if (!person.dob) return + const age = calculateAge(person.dob, photoDate) + if (age !== null && !agesCounted.has(age)) { + counts.set(age, (counts.get(age) || 0) + 1) + agesCounted.add(age) + } + }) + }) + + return uniqueAges + .map((age) => ({ age, count: counts.get(age) || 0 })) + .filter(({ count, age }) => count > 0 && !Number.isNaN(age)) + .sort((a, b) => a.age - b.age) + }, [keywordFiltered, uniqueAges, keyword]) + + const totalPhotoCount = useMemo( + () => keywordFiltered.filter((item) => item.persons?.some((person) => person.dob)).length, + [keywordFiltered], + ) + + const { peopleAtSelectedAge, peopleWithCounts } = useMemo(() => { + if (selectedAge === null) { + return { peopleAtSelectedAge: [], peopleWithCounts: [] } + } + + const matches: PersonMatch[] = [] + const counts = new Map() + + ageFiltered.forEach((item) => { + if (!item.persons || !item.filename) return + const photoDate = Array.isArray(item.filename) + ? item.filename[0].substring(0, 10) + : item.filename.substring(0, 10) + + item.persons.forEach((person) => { + if (!person.dob) return + const age = calculateAge(person.dob, photoDate) + if (age === selectedAge) { + matches.push({ + name: person.full, + age, + photoDate, + }) + counts.set(person.full, (counts.get(person.full) || 0) + 1) + } + }) + }) + + const uniquePeople = Array.from( + matches.reduce((acc, match) => { + if (!acc.has(match.name) || acc.get(match.name)!.photoDate > match.photoDate) { + acc.set(match.name, match) + } + return acc + }, new Map()), + ).map(([_, match]) => match.name).sort() + + return { + peopleAtSelectedAge: uniquePeople, + peopleWithCounts: uniquePeople.map((name) => ({ + name, + count: counts.get(name) || 0, + })), + } + }, [ageFiltered, selectedAge]) + + const { setViewed, memoryHtml } = useMemory(ageFiltered, refImageGallery) + const zooms = useMemo(() => ({ geo: { zoom: config.defaultZoom } }), []) + + return ( +
+ + History App - All + + + +
+ {searchBox} +
+ + + {selectedAge !== null && peopleAtSelectedAge.length > 0 && ( + + )} +
+
+ + {memoryHtml} + + +
+
+ ) +} + export const getStaticProps: GetStaticProps = async (context) => { const params = context.params! const { albums } = await getAlbums(params.gallery) @@ -77,38 +315,4 @@ export const getStaticPaths: GetStaticPaths = async () => { } } -function AllPage({ items = [], indexedKeywords }: ComponentProps) { - const refImageGallery = useRef(null) - const [memoryIndex, setMemoryIndex] = useState(0) - const { - filtered, - keyword, - searchBox, - } = useSearch({ items, setMemoryIndex, indexedKeywords }) - const { setViewed, memoryHtml } = useMemory(filtered, refImageGallery) - - const zooms = useMemo(() => ({ geo: { zoom: config.defaultZoom } }), [config.defaultZoom]) - - return ( -
- - History App - All - - - - {searchBox} - {memoryHtml} - - - -
- ) -} - export default AllPage diff --git a/src/components/AlbumPage/index.tsx b/src/components/AlbumPage/index.tsx index ff3f44d0..612134df 100644 --- a/src/components/AlbumPage/index.tsx +++ b/src/components/AlbumPage/index.tsx @@ -9,16 +9,9 @@ import SplitViewer from '../SplitViewer' import ThumbImg from '../ThumbImg' import styles from './styles.module.css' -import type { IndexedKeywords, Item } from '../../types/common' +import type { Album } from '../../types/pages' -interface ServerSidePhotoItem extends Item { - corpus: string; -} - -function AlbumPage( - { items = [], meta, indexedKeywords }: - { items: ServerSidePhotoItem[], meta?: object, indexedKeywords: IndexedKeywords[] }, -) { +function AlbumPage({ items = [], meta, indexedKeywords }: Album.ComponentProps) { const refImageGallery = useRef(null) const [memoryIndex, setMemoryIndex] = useState(0) const { diff --git a/src/components/GalleryPage/index.tsx b/src/components/GalleryPage/index.tsx new file mode 100644 index 00000000..4934a0e0 --- /dev/null +++ b/src/components/GalleryPage/index.tsx @@ -0,0 +1,30 @@ +import Head from 'next/head' + +import useSearch from '../../hooks/useSearch' +import { Gallery } from '../../types/pages' +import Galleries from '../Albums' +import Link from '../Link' + +function GalleryPage({ gallery, albums, indexedKeywords }: Gallery.ComponentProps) { + const { + filtered, + searchBox, + } = useSearch({ items: albums, indexedKeywords }) + + return ( + <> + + History App - List Albums + + +
{searchBox}
+
    +
  • View All
  • +
  • Today
  • +
+ + + ) +} + +export default GalleryPage diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx index b6149c27..7266e76c 100644 --- a/src/hooks/useSearch.tsx +++ b/src/hooks/useSearch.tsx @@ -12,10 +12,11 @@ interface ServerSideItem { function useSearch( { items, setMemoryIndex, indexedKeywords }: { items: ItemType[]; setMemoryIndex?: Function; indexedKeywords: IndexedKeywords[] }, -): { filtered: ItemType[]; keyword: string; setKeyword: Function; searchBox: JSX.Element; } { +): { filtered: ItemType[]; keyword: string; setKeyword: Function; searchBox: JSX.Element; setFiltered: Function; } { const router = useRouter() const [keyword, setKeyword] = useState(router.query.keyword?.toString() || '') const [selectedOption, setSelectedOption] = useState(null) + const [filteredItems, setFilteredItems] = useState(items) const getShareUrlStem = () => { if (router.asPath.includes('keyword=')) { @@ -55,6 +56,7 @@ function useSearch( keyword: '', setKeyword, searchBox: getSearchBox(items), + setFilteredItems, } useEffect(() => { @@ -68,9 +70,11 @@ function useSearch( setSelectedOption(newValue) } }, [router.isReady]) - if (!router.isReady) { - return defaultReturn + return { + ...defaultReturn, + setFiltered: () => {}, // Add missing setFiltered property + } } const AND_OPERATOR = '&&' @@ -93,6 +97,7 @@ function useSearch( return { filtered, + setFiltered: setFilteredItems, keyword, setKeyword, searchBox: getSearchBox(filtered), diff --git a/src/types/common.d.ts b/src/types/common.d.ts index 7ff02342..d1f05099 100644 --- a/src/types/common.d.ts +++ b/src/types/common.d.ts @@ -126,6 +126,10 @@ interface ServerSideAlbumItem extends GalleryAlbum { corpus: string; } +interface ServerSidePhotoItem extends Item { + corpus: string; +} + interface ServerSideAllItem extends Item { album?: NonNullable; gallery?: NonNullable; diff --git a/src/types/pages.d.ts b/src/types/pages.d.ts new file mode 100644 index 00000000..b94ffd41 --- /dev/null +++ b/src/types/pages.d.ts @@ -0,0 +1,32 @@ +import { type ParsedUrlQuery } from 'node:querystring' + +import type { + AlbumMeta, + IndexedKeywords, + ServerSideAlbumItem, + ServerSidePhotoItem, +} from './common' + +export namespace Gallery { + export type ComponentProps = { + gallery: NonNullable; + albums: ServerSideAlbumItem[]; + indexedKeywords: IndexedKeywords[]; + } + export interface Params extends ParsedUrlQuery { + gallery: NonNullable + } +} + +export namespace Album { + export type ComponentProps = { + items: ServerSidePhotoItem[]; + meta?: object; + indexedKeywords: IndexedKeywords[]; + } + + export interface Params extends ParsedUrlQuery { + gallery: NonNullable + album: NonNullable + } +}