├── jest.setup.ts ├── .github └── ISSUE_TEMPLATE │ ├── config.yaml │ ├── feature_request.md │ └── bug_report.md ├── vercel.json ├── app ├── api │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── error.tsx ├── admin │ ├── insights │ │ └── page.tsx │ ├── configuration │ │ ├── export.json │ │ │ └── route.ts │ │ └── page.tsx │ ├── layout.tsx │ ├── tags │ │ └── page.tsx │ ├── albums │ │ └── page.tsx │ ├── recipes │ │ └── page.tsx │ ├── photos │ │ └── updates │ │ │ └── page.tsx │ └── uploads │ │ └── page.tsx ├── global-error.tsx ├── film-demo │ └── page.tsx ├── not-found.tsx ├── feed.json │ └── route.ts ├── rss.xml │ └── route.ts ├── og │ └── all │ │ └── page.tsx ├── sign-in │ └── page.tsx ├── template-image │ └── route.tsx └── home-image │ └── route.tsx ├── public ├── favicon.ico ├── favicons │ ├── dark.png │ ├── light.png │ └── apple-touch-icon.png └── fonts │ ├── IBMPlexMono-Bold.ttf │ ├── IBMPlexMono-Light.ttf │ ├── IBMPlexMono-Thin.ttf │ ├── IBMPlexMono-Italic.ttf │ ├── IBMPlexMono-Medium.ttf │ ├── IBMPlexMono-Regular.ttf │ ├── IBMPlexMono-BoldItalic.ttf │ ├── IBMPlexMono-ExtraLight.ttf │ ├── IBMPlexMono-SemiBold.ttf │ ├── IBMPlexMono-ThinItalic.ttf │ ├── IBMPlexMono-LightItalic.ttf │ ├── IBMPlexMono-MediumItalic.ttf │ ├── IBMPlexMono-SemiBoldItalic.ttf │ └── IBMPlexMono-ExtraLightItalic.ttf ├── readme └── og-image-share.png ├── postcss.config.js ├── src ├── i18n │ ├── date-fns-locale-alias.ts │ └── state │ │ ├── server.ts │ │ ├── client.ts │ │ ├── AppTextProvider.tsx │ │ └── AppTextProviderClient.tsx ├── utility │ ├── sleep.ts │ ├── image.ts │ ├── promise.ts │ ├── nanoid.ts │ ├── useEscapeHandler.ts │ ├── useDelay.ts │ ├── array.ts │ ├── useOnPathChange.ts │ ├── useIsVisible.ts │ ├── timezone.ts │ ├── useScrollIntoView.ts │ ├── size.ts │ ├── useSupportsHover.ts │ ├── html.ts │ ├── useVisibility.ts │ ├── exif-format.ts │ ├── useMetaThemeColor.ts │ ├── useElementHeight.ts │ ├── cookie.ts │ ├── useViewportHeight.ts │ ├── usePrefersReducedMotion.ts │ ├── useHash.ts │ ├── useIsKeyBeingPressed.ts │ ├── useScrollDirection.ts │ ├── useClientSearchParams.ts │ ├── usePreventNavigation.ts │ └── key.ts ├── auth │ ├── cache.ts │ └── index.ts ├── recents │ ├── index.ts │ ├── data.ts │ ├── RecentsShareModal.tsx │ ├── RecentsOverview.tsx │ ├── meta.ts │ ├── PhotoRecents.tsx │ ├── RecentsOGTile.tsx │ ├── RecentsHeader.tsx │ └── RecentsImageResponse.tsx ├── app │ ├── actions.ts │ ├── Nav.tsx │ ├── ThemeColors.tsx │ ├── font.ts │ └── HomeImageResponse.tsx ├── year │ ├── index.ts │ ├── data.ts │ ├── YearShareModal.tsx │ ├── meta.ts │ ├── YearOverview.tsx │ ├── PhotoYear.tsx │ ├── YearOGTile.tsx │ ├── YearImageResponse.tsx │ └── YearHeader.tsx ├── platforms │ ├── fujifilm │ │ └── index.ts │ ├── vercel.ts │ ├── storage │ │ └── cache.ts │ ├── redis.ts │ ├── sony.ts │ ├── rate-limit.ts │ ├── safe-photo-image-response.ts │ └── next-image.ts ├── category │ ├── actions.ts │ └── cache.ts ├── components │ ├── icons │ │ ├── IconTag.tsx │ │ ├── IconCheck.tsx │ │ ├── IconCamera.tsx │ │ ├── IconEdit.tsx │ │ ├── IconPhoto.tsx │ │ ├── IconPlace.tsx │ │ ├── IconBroom.tsx │ │ ├── IconFilm.tsx │ │ ├── IconRecipe.tsx │ │ ├── IconUpload.tsx │ │ ├── IconYear.tsx │ │ ├── IconFolder.tsx │ │ ├── IconLens.tsx │ │ ├── IconSignOut.tsx │ │ ├── IconTrash.tsx │ │ ├── IconAlbum.tsx │ │ ├── IconRecents.tsx │ │ ├── IconSort.tsx │ │ ├── IconAddUpload.tsx │ │ ├── IconFocalLength.tsx │ │ ├── IconFavs.tsx │ │ ├── IconSelectChevron.tsx │ │ ├── IconHidden.tsx │ │ ├── IconLock.tsx │ │ ├── IconGrSync.tsx │ │ ├── IconSearch.tsx │ │ ├── IconFull.tsx │ │ └── IconGrid.tsx │ ├── index.ts │ ├── ScoreCardContainer.tsx │ ├── ExperimentalBadge.tsx │ ├── PageSpinner.tsx │ ├── QuotedContent.tsx │ ├── ErrorNote.tsx │ ├── WarningNote.tsx │ ├── image │ │ ├── ImageSmall.tsx │ │ ├── ImageMedium.tsx │ │ ├── index.ts │ │ └── ImageLarge.tsx │ ├── Tooltip.tsx │ ├── DivDebugBaselineGrid.tsx │ ├── switcher │ │ ├── Switcher.tsx │ │ └── SwitcherItemMenu.tsx │ ├── LinkWithLoaderBackground.tsx │ ├── FormWithConfirm.tsx │ ├── ScoreCard.tsx │ ├── shared-hover │ │ └── state.ts │ ├── primitives │ │ ├── Icon.tsx │ │ ├── MenuSurface.tsx │ │ └── ProgressButton.tsx │ ├── LinkWithIconLoader.tsx │ ├── SmallDisclosure.tsx │ ├── RepoLink.tsx │ ├── LoaderLink.tsx │ ├── HttpStatusPage.tsx │ ├── CopyButton.tsx │ ├── MaskedScroll.tsx │ └── DownloadButton.tsx ├── swr │ ├── SwrConfigClient.tsx │ └── index.ts ├── cache │ └── actions.ts ├── image-response │ ├── cache.ts │ ├── components │ │ └── ImageContainer.tsx │ └── index.ts ├── film │ ├── data.ts │ ├── FilmOverview.tsx │ ├── FilmShareModal.tsx │ ├── FilmOGTile.tsx │ └── FilmImageResponse.tsx ├── tag │ ├── data.ts │ ├── TagOverview.tsx │ ├── TagShareModal.tsx │ ├── PhotoPrivate.tsx │ ├── PhotoTags.tsx │ ├── PrivateHeader.tsx │ ├── TagOGTile.tsx │ ├── AlbumOGTile.tsx │ ├── PhotoFavs.tsx │ └── PhotoTag.tsx ├── recipe │ ├── data.ts │ ├── RecipeModal.tsx │ ├── RecipeOverview.tsx │ ├── RecipeOGTile.tsx │ ├── RecipeShareModal.tsx │ └── useRecipeOverlay.ts ├── focal │ ├── data.ts │ ├── FocalLengthOverview.tsx │ ├── FocalLengthShareModal.tsx │ ├── PhotoFocalLength.tsx │ └── FocalLengthOGTile.tsx ├── photo │ ├── key-commands.ts │ ├── update │ │ ├── server.ts │ │ └── UpdateTooltip.tsx │ ├── form │ │ ├── FieldsetFavs.tsx │ │ └── usePhotoFormParent.ts │ ├── PhotoShareModal.tsx │ ├── PhotoEscapeHandler.tsx │ ├── StaggeredOgPhotos.tsx │ ├── visibility │ │ ├── PhotoVisibilityIcon.tsx │ │ └── FieldsetVisibility.tsx │ ├── PhotoGridPage.tsx │ ├── StaggeredOgPhotosInfinite.tsx │ ├── PhotoOGTile.tsx │ ├── PhotoFullPage.tsx │ ├── color │ │ ├── PhotoColors.tsx │ │ ├── client.ts │ │ └── SyncColorButton.tsx │ ├── UpdateBlurDataButton.tsx │ ├── PhotosLargeInfinite.tsx │ ├── ai │ │ └── useTitleCaptionAiImageQuery.ts │ ├── PhotosLarge.tsx │ └── PhotoGridInfinite.tsx ├── album │ ├── data.ts │ ├── cache.ts │ ├── AlbumShareModal.tsx │ ├── AlbumOverview.tsx │ ├── FieldsetAlbum.tsx │ └── PhotoAlbum.tsx ├── admin │ ├── AdminInfoPage.tsx │ ├── upload │ │ └── index.ts │ ├── AdminAlbumBadge.tsx │ ├── EditButton.tsx │ ├── select │ │ ├── SelectPhotosState.ts │ │ └── AdminBatchEditPanel.tsx │ ├── AdminShowRecipeButton.tsx │ ├── confirm.ts │ ├── ClearCacheButton.tsx │ ├── AdminTagBadge.tsx │ ├── AdminTable.tsx │ ├── DeletePhotoButton.tsx │ ├── config │ │ ├── AdminAppConfigurationServer.tsx │ │ ├── AdminAppConfiguration.tsx │ │ └── AdminAppConfigurationSidebar.tsx │ ├── AdminEmptyState.tsx │ ├── AdminAppInfoIcon.tsx │ ├── AdminLink.tsx │ ├── DeleteButton.tsx │ ├── AdminRecipeBadge.tsx │ ├── AdminBadge.tsx │ ├── AdminUploadsTable.tsx │ └── insights │ │ └── InsightsIndicatorDot.tsx ├── place │ ├── actions.ts │ └── index.ts ├── lens │ ├── data.ts │ ├── LensShareModal.tsx │ ├── LensOverview.tsx │ ├── PhotoLens.tsx │ └── LensOGTile.tsx ├── camera │ ├── data.ts │ ├── CameraOverview.tsx │ ├── CameraShareModal.tsx │ └── CameraOGTile.tsx ├── toast │ ├── ToasterWithThemes.tsx │ └── index.tsx ├── cmdk │ └── CommandK.tsx ├── feed │ └── programmatic.ts ├── css │ ├── sonner.css │ └── viewerjs.css └── social │ └── SocialButton.tsx ├── .gitignore ├── __tests__ ├── fujifilm.test.ts ├── postgres.test.ts ├── html.test.ts ├── string.test.ts ├── category.test.ts └── photo.test.ts ├── jest.config.ts └── tsconfig.json /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill'; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "regions": [ 3 | "sin1" 4 | ] 5 | } -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from '@/auth/server'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/favicon.ico -------------------------------------------------------------------------------- /public/favicons/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/favicons/dark.png -------------------------------------------------------------------------------- /public/favicons/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/favicons/light.png -------------------------------------------------------------------------------- /readme/og-image-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/readme/og-image-share.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-Light.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-Thin.ttf -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-Italic.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-Medium.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-BoldItalic.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-ExtraLight.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-SemiBold.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-ThinItalic.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-LightItalic.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-MediumItalic.ttf -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /src/i18n/date-fns-locale-alias.ts: -------------------------------------------------------------------------------- 1 | // Dynamically resolves in next.config.ts 2 | export { enUS as default } from 'date-fns/locale/en-US'; 3 | -------------------------------------------------------------------------------- /public/fonts/IBMPlexMono-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzd/exif-photo-blog/main/public/fonts/IBMPlexMono-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /src/utility/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(ms: number): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/auth/cache.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { auth } from '@/auth/server'; 3 | 4 | export const authCachedSafe = cache(() => auth().catch(() => null)); 5 | -------------------------------------------------------------------------------- /src/recents/index.ts: -------------------------------------------------------------------------------- 1 | import { CategoryQueryMeta } from '@/category'; 2 | 3 | type RecentWithMeta = CategoryQueryMeta; 4 | 5 | export type Recents = RecentWithMeta[]; 6 | -------------------------------------------------------------------------------- /src/utility/image.ts: -------------------------------------------------------------------------------- 1 | export const removeBase64Prefix = (base64: string) => { 2 | return base64.match(/^data:image\/[a-z]{3,4};base64,(.+)$/)?.[1] ?? base64; 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { warmRedisConnection } from '@/platforms/redis'; 4 | 5 | export const warmRedisAction = async () => warmRedisConnection(); 6 | -------------------------------------------------------------------------------- /src/year/index.ts: -------------------------------------------------------------------------------- 1 | import { CategoryQueryMeta } from '@/category'; 2 | 3 | type YearWithMeta = { year: string } & CategoryQueryMeta; 4 | 5 | export type Years = YearWithMeta[]; 6 | -------------------------------------------------------------------------------- /src/platforms/fujifilm/index.ts: -------------------------------------------------------------------------------- 1 | export const MAKE_FUJIFILM = 'FUJIFILM'; 2 | 3 | export const isMakeFujifilm = (make?: string) => 4 | make?.toLocaleUpperCase() === MAKE_FUJIFILM; 5 | -------------------------------------------------------------------------------- /src/utility/promise.ts: -------------------------------------------------------------------------------- 1 | export const sleep = async (delay = 1000) => { 2 | return new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve('Ready'); 5 | }, delay); 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/category/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { getCountsForCategoriesCached } from './cache'; 4 | 5 | export const getCountsForCategoriesCachedAction = async () => 6 | getCountsForCategoriesCached(); 7 | -------------------------------------------------------------------------------- /src/components/icons/IconTag.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { FiTag } from 'react-icons/fi'; 3 | 4 | export default function IconTag(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconCheck.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { FaCheck } from 'react-icons/fa6'; 3 | 4 | export default function IconCheck(props: IconBaseProps) { 5 | return ; 6 | } -------------------------------------------------------------------------------- /src/components/icons/IconCamera.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { TbCamera } from 'react-icons/tb'; 3 | 4 | export default function IconCamera(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconEdit.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { FaRegEdit } from 'react-icons/fa'; 3 | 4 | export default function IconEdit(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconPhoto.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { TbPhoto } from 'react-icons/tb'; 3 | 4 | export default function IconPhoto(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconPlace.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { FaMapPin } from 'react-icons/fa6'; 3 | 4 | export default function IconYear(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconBroom.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { LiaBroomSolid } from 'react-icons/lia'; 3 | 4 | export default function IconBroom(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconFilm.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { IoFilmOutline } from 'react-icons/io5'; 3 | 4 | export default function IconFilm(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconRecipe.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { TbChecklist } from 'react-icons/tb'; 3 | 4 | export default function IconRecipe(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconUpload.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { FiUploadCloud } from 'react-icons/fi'; 3 | 4 | export default function IconUpload(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconYear.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { LuCalendarDays } from 'react-icons/lu'; 3 | 4 | export default function IconYear(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconFolder.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { FaRegFolderOpen } from 'react-icons/fa'; 3 | 4 | export default function IconFolder(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconLens.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { RiCameraLensLine } from 'react-icons/ri'; 3 | 4 | export default function IconLens(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/icons/IconSignOut.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { PiSignOutBold } from 'react-icons/pi'; 3 | 4 | export default function IconSignOut(props: IconBaseProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/i18n/state/server.ts: -------------------------------------------------------------------------------- 1 | import { APP_LOCALE } from '@/app/config'; 2 | import { getTextForLocale } from '..'; 3 | import { generateAppTextState } from '.'; 4 | 5 | export const getAppText = () => 6 | getTextForLocale(APP_LOCALE).then(generateAppTextState); 7 | -------------------------------------------------------------------------------- /app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HttpStatusPage from '@/components/HttpStatusPage'; 4 | 5 | export default function Error() { 6 | return ( 7 | 8 | Something went wrong 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/swr/SwrConfigClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { SWRConfig } from 'swr'; 4 | 5 | export default function SwrConfigClient({ 6 | children, 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | return 11 | {children} 12 | ; 13 | } 14 | -------------------------------------------------------------------------------- /app/admin/insights/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminAppInsights from '@/admin/insights/AdminAppInsights'; 2 | import AdminInfoPage from '@/admin/AdminInfoPage'; 3 | 4 | export default async function AdminInsightsPage() { 5 | return 6 | 7 | ; 8 | } 9 | -------------------------------------------------------------------------------- /src/cache/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { runAuthenticatedAdminServerAction } from '@/auth/server'; 4 | import { revalidateAllKeysAndPaths } from '.'; 5 | 6 | export const revalidateAllKeysAndPathsAction = async () => 7 | runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths); 8 | -------------------------------------------------------------------------------- /src/components/icons/IconTrash.tsx: -------------------------------------------------------------------------------- 1 | import { BiTrash } from 'react-icons/bi'; 2 | import { IconBaseProps } from 'react-icons/lib'; 3 | 4 | export default function IconTrash(props: IconBaseProps) { 5 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /app/admin/configuration/export.json/route.ts: -------------------------------------------------------------------------------- 1 | import { APP_CONFIGURATION, DEBUG_OUTPUTS_ENABLED } from '@/app/config'; 2 | 3 | export async function GET() { 4 | return DEBUG_OUTPUTS_ENABLED 5 | ? Response.json(APP_CONFIGURATION) 6 | : new Response('Debugging disabled', { status: 404 }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/utility/nanoid.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | 3 | const NANOID_LENGTH = 8; 4 | 5 | const NANOID_ALPHABET = 6 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 7 | 8 | export const generateNanoid = 9 | customAlphabet(NANOID_ALPHABET, NANOID_LENGTH); 10 | -------------------------------------------------------------------------------- /src/components/icons/IconAlbum.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { LuFolderClosed } from 'react-icons/lu'; 3 | 4 | export default function IconAlbum(props: IconBaseProps) { 5 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /src/utility/useEscapeHandler.ts: -------------------------------------------------------------------------------- 1 | import useKeydownHandler from '@/utility/useKeydownHandler'; 2 | 3 | export default function useEscapeHandler( 4 | args: Omit[0], 'keys'>, 5 | ) { 6 | useKeydownHandler({ 7 | ...args, 8 | keys: ['ESCAPE'], 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /app/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import AdminNav from '@/admin/AdminNav'; 2 | 3 | export default async function AdminLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 |
10 | 11 | {children} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { GRID_ASPECT_RATIO } from '@/app/config'; 2 | 3 | export const GRID_GAP_CLASSNAME = GRID_ASPECT_RATIO === 0 4 | ? 'gap-1 sm:gap-2' 5 | : 'gap-0.5 sm:gap-1'; 6 | 7 | export const GRID_SPACE_CLASSNAME = GRID_ASPECT_RATIO === 0 8 | ? 'space-y-1 sm:space-y-2' 9 | : 'space-y-0.5 sm:space-y-1'; 10 | -------------------------------------------------------------------------------- /app/global-error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HttpStatusPage from '@/components/HttpStatusPage'; 4 | import { TbRefresh } from 'react-icons/tb'; 5 | 6 | export default function GlobalError() { 7 | return ( 8 | }> 9 | Something went wrong 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/i18n/state/client.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext, use } from 'react'; 4 | import { generateAppTextState } from '.'; 5 | import { TEXT as EN_US } from '../locales/en-us'; 6 | 7 | export const AppTextContext = createContext(generateAppTextState(EN_US)); 8 | 9 | export const useAppText = () => use(AppTextContext); 10 | -------------------------------------------------------------------------------- /src/image-response/cache.ts: -------------------------------------------------------------------------------- 1 | export const getImageResponseCacheControlHeaders = ( 2 | shouldCache = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production', 3 | ) => { 4 | return { 5 | 'Cache-Control': shouldCache 6 | ? 's-maxage=3600, stale-while-revalidate=31536000' 7 | : 's-maxage=1, stale-while-revalidate=59', 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/recents/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPhotosCached, 3 | getPhotosMetaCached, 4 | } from '@/photo/cache'; 5 | 6 | export const getPhotosRecentsDataCached = ({ 7 | limit, 8 | }: { 9 | limit?: number, 10 | }) => 11 | Promise.all([ 12 | getPhotosCached({ recent: true, limit }), 13 | getPhotosMetaCached({ recent: true }), 14 | ]); 15 | -------------------------------------------------------------------------------- /src/film/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPhotosCached, 3 | getPhotosMetaCached, 4 | } from '@/photo/cache'; 5 | 6 | export const getPhotosFilmDataCached = ({ 7 | film, 8 | limit, 9 | }: { 10 | film: string, 11 | limit?: number, 12 | }) => 13 | Promise.all([ 14 | getPhotosCached({ film, limit }), 15 | getPhotosMetaCached({ film }), 16 | ]); 17 | -------------------------------------------------------------------------------- /src/tag/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPhotosCached, 3 | getPhotosMetaCached, 4 | } from '@/photo/cache'; 5 | 6 | export const getPhotosTagDataCached = ({ 7 | tag, 8 | limit, 9 | }: { 10 | tag: string, 11 | limit?: number, 12 | }) => 13 | Promise.all([ 14 | getPhotosCached({ tag, limit }), 15 | getPhotosMetaCached({ tag }), 16 | ]); 17 | 18 | -------------------------------------------------------------------------------- /src/year/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPhotosCached, 3 | getPhotosMetaCached, 4 | } from '@/photo/cache'; 5 | 6 | export const getPhotosYearDataCached = ({ 7 | year, 8 | limit, 9 | }: { 10 | year: string, 11 | limit?: number, 12 | }) => 13 | Promise.all([ 14 | getPhotosCached({ year, limit }), 15 | getPhotosMetaCached({ year }), 16 | ]); 17 | -------------------------------------------------------------------------------- /src/components/ScoreCardContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import clsx from 'clsx/lite'; 3 | 4 | export default function ScoreCardContainer({ 5 | children, 6 | }: { 7 | children: ReactNode 8 | }) { 9 | return
13 | {children} 14 |
; 15 | } 16 | -------------------------------------------------------------------------------- /src/recipe/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPhotosCached, 3 | getPhotosMetaCached, 4 | } from '@/photo/cache'; 5 | 6 | export const getPhotosRecipeDataCached = ({ 7 | recipe, 8 | limit, 9 | }: { 10 | recipe: string, 11 | limit?: number, 12 | }) => 13 | Promise.all([ 14 | getPhotosCached({ recipe, limit }), 15 | getPhotosMetaCached({ recipe }), 16 | ]); 17 | -------------------------------------------------------------------------------- /src/utility/useDelay.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useDelay(delay = 0) { 4 | const [didLoad, setDidLoad] = useState(false); 5 | 6 | useEffect(() => { 7 | const timeout = setTimeout(() => setDidLoad(true), delay); 8 | return () => clearTimeout(timeout); 9 | }, [delay]); 10 | 11 | return didLoad; 12 | }; 13 | -------------------------------------------------------------------------------- /src/focal/data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPhotosCached, 3 | getPhotosMetaCached, 4 | } from '@/photo/cache'; 5 | 6 | export const getPhotosFocalLengthDataCached = ({ 7 | focal, 8 | limit, 9 | }: { 10 | focal: number, 11 | limit?: number, 12 | }) => 13 | Promise.all([ 14 | getPhotosCached({ focal, limit }), 15 | getPhotosMetaCached({ focal }), 16 | ]); 17 | 18 | -------------------------------------------------------------------------------- /src/photo/key-commands.ts: -------------------------------------------------------------------------------- 1 | export const KEY_COMMANDS = { 2 | full: 'F', 3 | grid: 'G', 4 | admin: 'A', 5 | prev: ['J', 'ARROWLEFT'], 6 | next: ['L', 'ARROWRIGHT'], 7 | edit: 'E', 8 | favorite: 'P', 9 | unfavorite: 'X', 10 | togglePrivate: 'M', 11 | download: 'D', 12 | sync: 'S', 13 | search: ['⌘', 'K'], 14 | delete: ['⌘', 'BACKSPACE'], 15 | } as const; 16 | -------------------------------------------------------------------------------- /src/utility/array.ts: -------------------------------------------------------------------------------- 1 | function* chunkArrayGenerator( 2 | array: T[], 3 | chunkSize: number, 4 | ): Generator { 5 | for (let i = 0; i < array.length; i += chunkSize) { 6 | yield array.slice(i, i + chunkSize); 7 | } 8 | } 9 | 10 | export const chunkArray = (array: T[], chunkSize: number): T[][] => 11 | [...chunkArrayGenerator(array, chunkSize)]; 12 | -------------------------------------------------------------------------------- /src/album/data.ts: -------------------------------------------------------------------------------- 1 | import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache'; 2 | import { Album } from '.'; 3 | 4 | export const getPhotosAlbumDataCached = ({ 5 | album, 6 | limit, 7 | }: { 8 | album: Album, 9 | limit?: number, 10 | }) => 11 | Promise.all([ 12 | getPhotosCached({ album, limit }), 13 | getPhotosMetaCached({ album }), 14 | ]); 15 | 16 | -------------------------------------------------------------------------------- /src/components/icons/IconRecents.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { HiLightningBolt } from 'react-icons/hi'; 3 | import { TbBolt } from 'react-icons/tb'; 4 | 5 | export default function IconRecents({ 6 | solid, 7 | ...props 8 | }: IconBaseProps & { solid?: boolean}) { 9 | return solid 10 | ? 11 | : ; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/Nav.tsx: -------------------------------------------------------------------------------- 1 | import { getPhotosCached } from '@/photo/cache'; 2 | import NavClient from './NavClient'; 3 | import { NAV_CAPTION, NAV_TITLE } from './config'; 4 | 5 | export default async function Nav() { 6 | const photos = await getPhotosCached({ limit: 1 }).catch(() => []); 7 | return 0} 11 | />; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/icons/IconSort.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { HiSortAscending, HiSortDescending } from 'react-icons/hi'; 3 | 4 | export default function IconSort({ 5 | sort = 'desc', 6 | ...props 7 | }: IconBaseProps & { sort?: 'desc' | 'asc' }) { 8 | return sort === 'desc' 9 | ? 10 | : ; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/ThemeColors.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MATTE_COLOR, MATTE_COLOR_DARK } from './config'; 4 | 5 | export default function ThemeColors() { 6 | return (<> 7 | {MATTE_COLOR && } 10 | {MATTE_COLOR_DARK && } 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/utility/useOnPathChange.ts: -------------------------------------------------------------------------------- 1 | import { usePathname } from 'next/navigation'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | export default function useOnPathChange(onPathChange: () => void) { 5 | const path = usePathname(); 6 | 7 | const initialPath = useRef(path); 8 | 9 | useEffect(() => { 10 | if (initialPath.current !== path) { 11 | onPathChange(); 12 | } 13 | }, [path, onPathChange]); 14 | } 15 | -------------------------------------------------------------------------------- /src/category/cache.ts: -------------------------------------------------------------------------------- 1 | import { unstable_cache } from 'next/cache'; 2 | import { getCountsForCategories, getDataForCategories } from './data'; 3 | import { KEY_PHOTOS } from '@/cache'; 4 | 5 | export const getDataForCategoriesCached = unstable_cache( 6 | getDataForCategories, 7 | [KEY_PHOTOS], 8 | ); 9 | 10 | export const getCountsForCategoriesCached = unstable_cache( 11 | getCountsForCategories, 12 | [KEY_PHOTOS], 13 | ); 14 | -------------------------------------------------------------------------------- /src/components/icons/IconAddUpload.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx/lite'; 2 | import { IconBaseProps } from 'react-icons'; 3 | import { BiImageAdd } from 'react-icons/bi'; 4 | 5 | export default function IconAddUpload({ 6 | className, 7 | size, 8 | ...props 9 | }: IconBaseProps) { 10 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /src/platforms/vercel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IS_PREVIEW, 3 | VERCEL_BYPASS_KEY, 4 | VERCEL_BYPASS_SECRET, 5 | } from '@/app/config'; 6 | 7 | export const fetchWithBypass: typeof fetch = (url, options) => 8 | IS_PREVIEW && VERCEL_BYPASS_SECRET 9 | ? fetch(url, { 10 | ...options, 11 | headers: { 12 | ...options?.headers, 13 | [VERCEL_BYPASS_KEY]: VERCEL_BYPASS_SECRET, 14 | }, 15 | }) 16 | : fetch(url, options); 17 | -------------------------------------------------------------------------------- /src/components/icons/IconFocalLength.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { TbCone } from 'react-icons/tb'; 3 | 4 | export default function IconFocalLength({ 5 | style, 6 | ...props 7 | }: IconBaseProps) { 8 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /src/photo/update/server.ts: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDbInsert } from '..'; 2 | import { doesPhotoUrlHaveOptimizedFiles } from '../storage'; 3 | 4 | // Used to anonymize storage/create optimized files if necessary 5 | // by re-running convertUploadToPhoto (image upload transfer logic) 6 | export const shouldBackfillPhotoStorage = async ( 7 | photo: Photo | PhotoDbInsert, 8 | ) => 9 | photo.url.includes(photo.id) || 10 | !await doesPhotoUrlHaveOptimizedFiles(photo.url); 11 | -------------------------------------------------------------------------------- /src/components/icons/IconFavs.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx/lite'; 2 | import { IconBaseProps } from 'react-icons'; 3 | import { FaRegStar, FaStar } from 'react-icons/fa6'; 4 | 5 | export default function IconFavs({ 6 | highlight, 7 | className, 8 | ...props 9 | }: IconBaseProps & { highlight?: boolean}) { 10 | return highlight 11 | ? 15 | : ; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/icons/IconSelectChevron.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { IconBaseProps } from 'react-icons'; 3 | import { FiChevronDown } from 'react-icons/fi'; 4 | 5 | export default function IconSelectChevron({ 6 | className, 7 | ...props 8 | }: IconBaseProps) { 9 | return ( 10 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/admin/AdminInfoPage.tsx: -------------------------------------------------------------------------------- 1 | import Container from '@/components/Container'; 2 | import AppGrid from '@/components/AppGrid'; 3 | import { ComponentProps } from 'react'; 4 | 5 | export default function AdminInfoPage({ 6 | children, 7 | ...props 8 | }: Omit, 'contentMain'>) { 9 | return ( 10 | 14 | {children} 15 | } 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/admin/upload/index.ts: -------------------------------------------------------------------------------- 1 | export interface UploadState { 2 | isUploading: boolean 3 | uploadError: string 4 | debugDownload?: { href: string, fileName: string } 5 | hideUploadPanel?: boolean 6 | fileUploadName: string 7 | fileUploadIndex: number 8 | filesLength: number 9 | } 10 | 11 | export const INITIAL_UPLOAD_STATE: UploadState = { 12 | isUploading: false, 13 | uploadError: '', 14 | hideUploadPanel: false, 15 | fileUploadName: '', 16 | fileUploadIndex: 0, 17 | filesLength: 0, 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/ExperimentalBadge.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx/lite'; 2 | import Badge from './Badge'; 3 | 4 | export default function ExperimentalBadge({ 5 | className, 6 | }: { 7 | className?: string 8 | }) { 9 | return ( 10 | 18 | Experimental 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/PageSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx/lite'; 2 | import Spinner from './Spinner'; 3 | import AppGrid from './AppGrid'; 4 | 5 | export default function PageSpinner() { 6 | return ( 7 | 12 | 16 | 17 | } /> 18 | ); 19 | } -------------------------------------------------------------------------------- /src/admin/AdminAlbumBadge.tsx: -------------------------------------------------------------------------------- 1 | import AdminBadge from './AdminBadge'; 2 | import { Album } from '@/album'; 3 | import PhotoAlbum from '@/album/PhotoAlbum'; 4 | 5 | export default async function AdminAlbumBadge({ 6 | album, 7 | count, 8 | hideBadge, 9 | }: { 10 | album: Album, 11 | count: number, 12 | hideBadge?: boolean, 13 | }) { 14 | return ( 15 | } 17 | count={count} 18 | hideBadge={hideBadge} 19 | /> 20 | ); 21 | } -------------------------------------------------------------------------------- /src/components/QuotedContent.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | 3 | export default function QuotedContent({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode 8 | className?: string 9 | }) { 10 | return
18 | {children} 19 |
; 20 | } 21 | -------------------------------------------------------------------------------- /app/film-demo/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS, 3 | } from '@/platforms/fujifilm/simulation'; 4 | import PhotoFilm from '@/film/PhotoFilm'; 5 | 6 | export default function FilmPage() { 7 | return ( 8 |
9 | {FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS.map(({ value }) => 10 |
11 | 15 |
)} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/i18n/state/AppTextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { getTextForLocale } from '..'; 3 | import { APP_LOCALE } from '@/app/config'; 4 | import AppTextProviderClient from './AppTextProviderClient'; 5 | 6 | export default async function AppTextProvider({ 7 | children, 8 | }: { 9 | children: ReactNode 10 | }) { 11 | const value = await getTextForLocale(APP_LOCALE); 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/i18n/state/AppTextProviderClient.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode } from 'react'; 4 | import { AppTextContext } from './client'; 5 | import { I18N } from '..'; 6 | import { generateAppTextState } from '.'; 7 | 8 | export default function AppTextProviderClient({ 9 | children, 10 | value, 11 | }: { 12 | children: ReactNode 13 | value: I18N 14 | }) { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ErrorNote.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { BiErrorAlt } from 'react-icons/bi'; 3 | import Note from './Note'; 4 | 5 | export default function ErrorNote({ 6 | className, 7 | children, 8 | }: { 9 | className?: string 10 | children: ReactNode 11 | }) { 12 | return ( 13 | } 18 | > 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/icons/IconHidden.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { IconBaseProps } from 'react-icons'; 3 | import { AiOutlineEyeInvisible, AiOutlineEye } from 'react-icons/ai'; 4 | 5 | export default function IconHidden({ 6 | visible, 7 | ...props 8 | }: IconBaseProps & { 9 | visible?: boolean 10 | }) { 11 | // Flip so slash goes left to right 12 | props.className = clsx('-scale-x-100', props.className); 13 | return visible 14 | ? 15 | : ; 16 | } 17 | -------------------------------------------------------------------------------- /src/admin/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import IconEdit from '@/components/icons/IconEdit'; 2 | import PathLoaderButton from '@/components/primitives/PathLoaderButton'; 3 | import { ComponentProps } from 'react'; 4 | 5 | export default function EditButton ({ 6 | children, 7 | ...props 8 | }: ComponentProps) { 9 | return ( 10 | } 13 | > 14 | {children || 'Edit'} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/platforms/storage/cache.ts: -------------------------------------------------------------------------------- 1 | import { unstable_noStore } from 'next/cache'; 2 | import { 3 | getStoragePhotoUrls, 4 | getStorageUploadUrls, 5 | } from '@/photo/storage'; 6 | 7 | export const getStorageUploadUrlsNoStore: typeof getStorageUploadUrls = 8 | (...args) => { 9 | unstable_noStore(); 10 | return getStorageUploadUrls(...args); 11 | }; 12 | 13 | export const getStoragePhotoUrlsNoStore: typeof getStoragePhotoUrls = 14 | (...args) => { 15 | unstable_noStore(); 16 | return getStoragePhotoUrls(...args); 17 | }; 18 | -------------------------------------------------------------------------------- /app/admin/tags/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminTagsTable from '@/admin/AdminTagsTable'; 2 | import AppGrid from '@/components/AppGrid'; 3 | import { getUniqueTags } from '@/photo/query'; 4 | 5 | export default async function AdminTagsPage() { 6 | const tags = await getUniqueTags().catch(() => []); 7 | 8 | return ( 9 | 12 |
13 | 14 |
15 | } 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/utility/useIsVisible.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useState } from 'react'; 2 | import useVisibility from './useVisibility'; 3 | 4 | export default function useIsVisible({ 5 | ref, 6 | initiallyVisible = false, 7 | }: { 8 | ref: RefObject 9 | initiallyVisible?: boolean 10 | }) { 11 | const [isVisible, setIsVisible] = useState(initiallyVisible); 12 | 13 | useVisibility({ 14 | ref, 15 | onVisible: () => setIsVisible(true), 16 | onHidden: () => setIsVisible(false), 17 | }); 18 | 19 | return isVisible; 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem?** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the feature/solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Live deployment** 17 | If applicable, add url to latest deployment/hosted website. 18 | -------------------------------------------------------------------------------- /app/admin/albums/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminAlbumsTable from '@/admin/AdminAlbumsTable'; 2 | import { getAlbumsWithMeta } from '@/album/query'; 3 | import AppGrid from '@/components/AppGrid'; 4 | 5 | export default async function AdminTagsPage() { 6 | const albums = await getAlbumsWithMeta(); 7 | 8 | return ( 9 | 12 |
13 | 14 |
15 | } 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/WarningNote.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { PiWarningBold } from 'react-icons/pi'; 3 | import Note from './Note'; 4 | 5 | export default function WarningNote({ 6 | className, 7 | children, 8 | }: { 9 | className?: string 10 | children: ReactNode 11 | }) { 12 | return ( 13 | } 18 | > 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/photo/update/UpdateTooltip.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { getPhotoUpdateStatusText } from '.'; 3 | import Tooltip from '@/components/Tooltip'; 4 | import { Photo } from '..'; 5 | 6 | export default function UpdateTooltip({ 7 | photo, 8 | }: { 9 | photo: Photo 10 | }) { 11 | return ( 12 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/photo/form/FieldsetFavs.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | import FieldsetWithStatus from '@/components/FieldsetWithStatus'; 3 | import IconFavs from '@/components/icons/IconFavs'; 4 | 5 | export default function FieldsetFavs(props: Omit< 6 | ComponentProps, 7 | 'label' | 'icon' | 'type' 8 | >) { 9 | return ( 10 | } 15 | /> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # temp files 38 | temp.jpg 39 | -------------------------------------------------------------------------------- /__tests__/fujifilm.test.ts: -------------------------------------------------------------------------------- 1 | import { processTone } from '@/platforms/fujifilm/recipe'; 2 | 3 | describe('Fujifilm', () => { 4 | describe('recipes', () => { 5 | it('process tone', () => { 6 | expect(processTone(0)).toBe(0); 7 | expect(processTone(8)).toBe(-0.5); 8 | expect(processTone(16)).toBe(-1); 9 | expect(processTone(32)).toBe(-2); 10 | expect(processTone(-16)).toBe(1); 11 | expect(processTone(-32)).toBe(2); 12 | expect(processTone(-48)).toBe(3); 13 | expect(processTone(-64)).toBe(4); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /app/admin/recipes/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminRecipeTable from '@/admin/AdminRecipeTable'; 2 | import AppGrid from '@/components/AppGrid'; 3 | import { getUniqueRecipes } from '@/photo/query'; 4 | 5 | export default async function AdminRecipesPage() { 6 | const recipes = await getUniqueRecipes().catch(() => []); 7 | 8 | return ( 9 | 12 |
13 | 14 |
15 | } 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/admin/photos/updates/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminPhotosUpdateClient from '@/admin/AdminPhotosUpdateClient'; 2 | import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config'; 3 | import { getPhotosInNeedOfUpdate } from '@/photo/query'; 4 | 5 | export const maxDuration = 60; 6 | 7 | export default async function AdminUpdatesPage() { 8 | const photos = await getPhotosInNeedOfUpdate() 9 | .catch(() => []); 10 | 11 | return ( 12 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/image/ImageSmall.tsx: -------------------------------------------------------------------------------- 1 | import { IMAGE_WIDTH_SMALL, CustomImageProps } from '.'; 2 | import ImageWithFallback from './ImageWithFallback'; 3 | 4 | export default function ImageSmall(props: CustomImageProps) { 5 | const { 6 | aspectRatio, 7 | blurCompatibilityMode, 8 | ...rest 9 | } = props; 10 | return ( 11 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/place/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { runAuthenticatedAdminServerAction } from '@/auth/server'; 4 | import { 5 | getPlaceAutocomplete, 6 | getPlaceDetails, 7 | } from '@/platforms/google-places'; 8 | 9 | export const getPlaceAutoCompleteAction = 10 | async (...args: Parameters) => 11 | runAuthenticatedAdminServerAction(() => getPlaceAutocomplete(...args)); 12 | 13 | export const getPlaceDetailsAction = 14 | async (...args: Parameters) => 15 | runAuthenticatedAdminServerAction(() => getPlaceDetails(...args)); 16 | -------------------------------------------------------------------------------- /src/components/image/ImageMedium.tsx: -------------------------------------------------------------------------------- 1 | import { IMAGE_WIDTH_MEDIUM, CustomImageProps } from '.'; 2 | import ImageWithFallback from './ImageWithFallback'; 3 | 4 | export default function ImageMedium(props: CustomImageProps) { 5 | const { 6 | aspectRatio, 7 | blurCompatibilityMode, 8 | ...rest 9 | } = props; 10 | return ( 11 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/place/index.ts: -------------------------------------------------------------------------------- 1 | export interface PlaceAutocomplete { 2 | id: string 3 | text: string 4 | secondary?: string 5 | } 6 | 7 | export interface Place { 8 | id: string 9 | name: string 10 | nameFormatted: string 11 | link: string 12 | location?: Location 13 | viewport?: { low: Location, high: Location } 14 | } 15 | 16 | type Location = { 17 | latitude: number 18 | longitude: number 19 | } 20 | 21 | export const convertPlaceToAutocomplete = ( 22 | place?: Place, 23 | ): PlaceAutocomplete | undefined => place 24 | ? { id: place.id, text: place.name } 25 | : undefined; 26 | -------------------------------------------------------------------------------- /src/utility/timezone.ts: -------------------------------------------------------------------------------- 1 | import { getCookie, storeCookie } from './cookie'; 2 | 3 | // Timezone 4 | // string: timezone 5 | // undefined: timezone must be resolved on the client 6 | // null: timezone not required 7 | export type Timezone = string | undefined | null; 8 | 9 | export const TIMEZONE_COOKIE_NAME = 'timezone-client'; 10 | 11 | export const storeTimezoneCookie = () => 12 | storeCookie( 13 | TIMEZONE_COOKIE_NAME, 14 | Intl.DateTimeFormat().resolvedOptions().timeZone, 15 | ); 16 | 17 | export const getTimezoneCookie = () => 18 | getCookie(TIMEZONE_COOKIE_NAME); 19 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HttpStatusPage from '@/components/HttpStatusPage'; 4 | import { clsx } from 'clsx/lite'; 5 | import { usePathname } from 'next/navigation'; 6 | 7 | export default function NotFound() { 8 | const pathname = usePathname(); 9 | 10 | return ( 11 | 12 | 16 | {pathname} 17 | 18 | {' '} 19 | could not be found 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, ReactNode } from 'react'; 2 | import TooltipPrimitive from './primitives/TooltipPrimitive'; 3 | import { IoInformationCircleOutline } from 'react-icons/io5'; 4 | 5 | export default function Tooltip({ 6 | children, 7 | ...rest 8 | }: Omit, 'children'> & { 9 | children?: ReactNode 10 | }) { 11 | return ( 12 | 13 | {children ?? 14 | 15 | } 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/image-response/components/ImageContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export default function ImageContainer({ 4 | solidBackground, 5 | children, 6 | }: { 7 | solidBackground?: boolean 8 | children: ReactNode 9 | }) { 10 | return ( 11 |
20 | {children} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/utility/useScrollIntoView.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | import { isElementEntirelyInViewport } from '@/utility/dom'; 3 | 4 | export default function useScrollIntoView({ 5 | ref, 6 | shouldScrollIntoView, 7 | }: { 8 | ref?: RefObject 9 | shouldScrollIntoView?: boolean 10 | }) { 11 | useEffect(() => { 12 | if ( 13 | ref?.current && 14 | !isElementEntirelyInViewport(ref.current) && 15 | shouldScrollIntoView 16 | ) { 17 | ref.current.scrollIntoView({ behavior: 'smooth' }); 18 | } 19 | }, [ref, shouldScrollIntoView]); 20 | } 21 | -------------------------------------------------------------------------------- /app/feed.json/route.ts: -------------------------------------------------------------------------------- 1 | import { getPhotosCached } from '@/photo/cache'; 2 | import { SITE_FEEDS_ENABLED } from '@/app/config'; 3 | import { formatFeedJson } from '@/feed/json'; 4 | import { PROGRAMMATIC_QUERY_OPTIONS } from '@/feed'; 5 | 6 | // Cache for 24 hours 7 | export const revalidate = 86_400; 8 | 9 | export async function GET() { 10 | if (SITE_FEEDS_ENABLED) { 11 | const photos = await getPhotosCached(PROGRAMMATIC_QUERY_OPTIONS) 12 | .catch(() => []); 13 | return Response.json(formatFeedJson(photos)); 14 | } else { 15 | return new Response('Feeds disabled', { status: 404 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lens/data.ts: -------------------------------------------------------------------------------- 1 | import { formatLensParams, lensFromPhoto } from '.'; 2 | import { 3 | getPhotosCached, 4 | getPhotosMetaCached, 5 | } from '@/photo/cache'; 6 | 7 | export const getPhotosLensDataCached = async ( 8 | make: string | undefined, 9 | model: string, 10 | limit: number, 11 | ) => { 12 | const lens = formatLensParams({ make, model }); 13 | return Promise.all([ 14 | getPhotosCached({ lens, limit }), 15 | getPhotosMetaCached({ lens }), 16 | ]) 17 | .then(([photos, meta]) => [ 18 | photos, 19 | meta, 20 | lensFromPhoto(photos[0], lens), 21 | ] as const); 22 | }; 23 | -------------------------------------------------------------------------------- /src/camera/data.ts: -------------------------------------------------------------------------------- 1 | import { cameraFromPhoto, formatCameraParams } from '.'; 2 | import { 3 | getPhotosCached, 4 | getPhotosMetaCached, 5 | } from '@/photo/cache'; 6 | 7 | export const getPhotosCameraDataCached = async ( 8 | make: string, 9 | model: string, 10 | limit: number, 11 | ) => { 12 | const camera = formatCameraParams({ make, model }); 13 | return Promise.all([ 14 | getPhotosCached({ camera, limit }), 15 | getPhotosMetaCached({ camera }), 16 | ]) 17 | .then(([photos, meta]) => [ 18 | photos, 19 | meta, 20 | cameraFromPhoto(photos[0], camera), 21 | ] as const); 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/font.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { cwd } from 'process'; 4 | 5 | const FONT_IBM_PLEX_MONO_FAMILY = 'IBMPlexMono'; 6 | const FONT_IBM_PLEX_MONO_PATH = '/public/fonts/IBMPlexMono-Medium.ttf'; 7 | 8 | const getFontData = async () => 9 | fs.readFileSync(path.join(cwd(), FONT_IBM_PLEX_MONO_PATH)); 10 | 11 | export const getIBMPlexMono = () => getFontData() 12 | .then(data => ({ 13 | fontFamily: FONT_IBM_PLEX_MONO_FAMILY, 14 | fonts: [{ 15 | name: FONT_IBM_PLEX_MONO_FAMILY, 16 | data, 17 | weight: 500, 18 | style: 'normal', 19 | } as const], 20 | })); 21 | -------------------------------------------------------------------------------- /src/components/DivDebugBaselineGrid.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useAppState } from '@/app/AppState'; 4 | import { clsx } from 'clsx/lite'; 5 | import { HTMLAttributes } from 'react'; 6 | 7 | export default function DivDebugBaselineGrid({ 8 | children, 9 | className, 10 | ...props 11 | }: HTMLAttributes) { 12 | const { shouldShowBaselineGrid } = useAppState(); 13 | 14 | return ( 15 |
22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/image/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | import ImageWithFallback from './ImageWithFallback'; 3 | 4 | // Height determined by intrinsic photo aspect ratio 5 | export const IMAGE_WIDTH_SMALL = 50; 6 | // Height determined by intrinsic photo aspect ratio 7 | export const IMAGE_WIDTH_MEDIUM = 300; 8 | // Height determined by intrinsic photo aspect ratio 9 | export const IMAGE_WIDTH_LARGE = 1000; 10 | 11 | export type CustomImageProps = Omit< 12 | ComponentProps, 13 | 'blurCompatibilityLevel' 14 | > & { 15 | aspectRatio: number 16 | blurCompatibilityMode?: boolean 17 | } 18 | -------------------------------------------------------------------------------- /__tests__/postgres.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { generateManyToManyValues } from '@/db'; 3 | 4 | describe('Postgres', () => { 5 | it('Create many to many values', () => { 6 | expect(generateManyToManyValues(['1'], ['3'])) 7 | .toEqual({ 8 | valueString: 'VALUES ($1,$2)', 9 | values: ['1', '3'], 10 | }); 11 | expect(generateManyToManyValues(['1', '2'], ['3', '4', '5'])) 12 | .toEqual({ 13 | valueString: 'VALUES ($1,$2),($3,$4),($5,$6),($7,$8),($9,$10),($11,$12)', 14 | values: ['1', '3', '1', '4', '1', '5', '2', '3', '2', '4', '2', '5'], 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/switcher/Switcher.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { clsx } from 'clsx/lite'; 3 | 4 | export default function Switcher({ 5 | children, 6 | type = 'regular', 7 | className, 8 | }: { 9 | children: ReactNode 10 | type?: 'regular' | 'borderless' 11 | className?: string 12 | }) { 13 | return ( 14 |
22 | {children} 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/photo/PhotoShareModal.tsx: -------------------------------------------------------------------------------- 1 | import PhotoOGTile from '@/photo/PhotoOGTile'; 2 | import { absolutePathForPhoto } from '@/app/path'; 3 | import { Photo, titleForPhoto } from '.'; 4 | import { PhotoSetCategory } from '../category'; 5 | import ShareModal from '@/share/ShareModal'; 6 | 7 | export default function PhotoShareModal( 8 | props: { photo: Photo } & PhotoSetCategory, 9 | ) { 10 | return ( 11 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/admin/select/SelectPhotosState.ts: -------------------------------------------------------------------------------- 1 | import { createContext, Dispatch, SetStateAction, use } from 'react'; 2 | 3 | export type SelectPhotosState = { 4 | canCurrentPageSelectPhotos?: boolean 5 | isSelectingPhotos?: boolean; 6 | startSelectingPhotos?: () => void 7 | stopSelectingPhotos?: () => void 8 | selectedPhotoIds?: string[] 9 | setSelectedPhotoIds?: (photoIds: string[]) => void 10 | isPerformingSelectEdit?: boolean 11 | setIsPerformingSelectEdit?: Dispatch> 12 | }; 13 | 14 | export const SelectPhotosContext = createContext({}); 15 | 16 | export const useSelectPhotosState = () => use(SelectPhotosContext); 17 | -------------------------------------------------------------------------------- /src/admin/AdminShowRecipeButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import LoaderButton from '@/components/primitives/LoaderButton'; 4 | import { RecipeProps } from '@/recipe'; 5 | import { useAppState } from '@/app/AppState'; 6 | import { TbChecklist } from 'react-icons/tb'; 7 | 8 | export default function AdminShowRecipeButton(props: RecipeProps) { 9 | const { setRecipeModalProps } = useAppState(); 10 | 11 | return ( 12 | } 17 | onClick={() => setRecipeModalProps?.(props)} 18 | > 19 | Preview 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import type { Config } from 'jest'; 3 | import nextJest from 'next/jest.js'; 4 | 5 | const createJestConfig = nextJest({ 6 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 7 | dir: './', 8 | }); 9 | 10 | // Add any custom config to be passed to Jest 11 | const config: Config = { 12 | coverageProvider: 'v8', 13 | testEnvironment: 'jsdom', 14 | setupFilesAfterEnv: ['/jest.setup.ts'], 15 | }; 16 | 17 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 18 | export default createJestConfig(config); 19 | -------------------------------------------------------------------------------- /src/admin/confirm.ts: -------------------------------------------------------------------------------- 1 | import { Photo } from '@/photo'; 2 | 3 | export const syncPhotoConfirmText = ( 4 | photo: Photo, 5 | hasAiTextGeneration: boolean, 6 | onlySyncColorData?: boolean, 7 | ) => { 8 | const confirmText = ['Sync']; 9 | if (photo.title) { confirmText.push(`"${photo.title}"`); } 10 | if (onlySyncColorData) { 11 | confirmText.push('color data?'); 12 | } else { 13 | confirmText.push('data from original image file?'); 14 | if (hasAiTextGeneration) { confirmText.push( 15 | 'AI text will be generated for undefined fields.'); } 16 | } 17 | confirmText.push('This action cannot be undone.'); 18 | return confirmText.join(' '); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/image/ImageLarge.tsx: -------------------------------------------------------------------------------- 1 | import { IMAGE_QUALITY } from '@/app/config'; 2 | import { IMAGE_WIDTH_LARGE, CustomImageProps } from '.'; 3 | import ImageWithFallback from './ImageWithFallback'; 4 | 5 | export default function ImageLarge(props: CustomImageProps) { 6 | const { 7 | aspectRatio, 8 | blurCompatibilityMode, 9 | ...rest 10 | } = props; 11 | return ( 12 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/photo/PhotoEscapeHandler.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { getEscapePath } from '@/app/path'; 4 | import { useRouter, usePathname } from 'next/navigation'; 5 | import { useCallback } from 'react'; 6 | import useEscapeHandler from '../utility/useEscapeHandler'; 7 | 8 | export default function PhotoEscapeHandler() { 9 | const router = useRouter(); 10 | 11 | const pathname = usePathname(); 12 | 13 | const escapePath = getEscapePath(pathname); 14 | 15 | const onKeyDown = useCallback(() => { 16 | if (escapePath) { router.push(escapePath, { scroll: false }); } 17 | }, [escapePath, router]); 18 | 19 | useEscapeHandler({ onKeyDown }); 20 | 21 | return null; 22 | } 23 | -------------------------------------------------------------------------------- /src/admin/ClearCacheButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; 4 | import { clearCacheAction } from '@/photo/actions'; 5 | import { useAppState } from '@/app/AppState'; 6 | import { BiTrash } from 'react-icons/bi'; 7 | 8 | export default function ClearCacheButton() { 9 | const { invalidateSwr } = useAppState(); 10 | 11 | return ( 12 |
13 | } 15 | hideText="never" 16 | onFormSubmit={invalidateSwr} 17 | > 18 | Clear Cache 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/rss.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { getPhotosCached } from '@/photo/cache'; 2 | import { SITE_FEEDS_ENABLED } from '@/app/config'; 3 | import { formatFeedRssXml } from '@/feed/rss'; 4 | import { PROGRAMMATIC_QUERY_OPTIONS } from '@/feed'; 5 | 6 | // Cache for 24 hours 7 | export const revalidate = 86_400; 8 | 9 | export async function GET() { 10 | if (SITE_FEEDS_ENABLED) { 11 | const photos = await getPhotosCached(PROGRAMMATIC_QUERY_OPTIONS) 12 | .catch(() => []); 13 | return new Response( 14 | formatFeedRssXml(photos), 15 | { headers: { 'Content-Type': 'text/xml' } }, 16 | ); 17 | } else { 18 | return new Response('Feeds disabled', { status: 404 }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/admin/select/AdminBatchEditPanel.tsx: -------------------------------------------------------------------------------- 1 | import { getUniqueTagsCached } from '@/photo/cache'; 2 | import { getAlbumsWithMetaCached } from '@/album/cache'; 3 | import AdminBatchEditPanelClient from './AdminBatchEditPanelClient'; 4 | 5 | export default async function AdminBatchEditPanel({ 6 | onBatchActionComplete, 7 | }: { 8 | onBatchActionComplete?: () => Promise 9 | }) { 10 | const uniqueAlbums = await getAlbumsWithMetaCached().catch(() => []); 11 | const uniqueTags = await getUniqueTagsCached().catch(() => []); 12 | return ( 13 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/platforms/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis'; 2 | 3 | const KEY_TEST = 'test'; 4 | 5 | export const redis = ( 6 | process.env.KV_URL || 7 | process.env.UPSTASH_REDIS_REST_URL 8 | ) ? Redis.fromEnv() 9 | : ( 10 | process.env.EXIF_KV_REST_API_URL && 11 | process.env.EXIF_KV_REST_API_TOKEN 12 | ) ? new Redis({ 13 | url: process.env.EXIF_KV_REST_API_URL, 14 | token: process.env.EXIF_KV_REST_API_TOKEN, 15 | }) 16 | : undefined; 17 | 18 | export const warmRedisConnection = () => { 19 | if (redis) { redis.get(KEY_TEST); } 20 | }; 21 | 22 | export const testRedisConnection = () => redis 23 | ? redis.get(KEY_TEST) 24 | : Promise.reject(false); 25 | -------------------------------------------------------------------------------- /src/admin/AdminTagBadge.tsx: -------------------------------------------------------------------------------- 1 | import PhotoTag from '@/tag/PhotoTag'; 2 | import PhotoFavs from '@/tag/PhotoFavs'; 3 | import { isTagFavs } from '@/tag'; 4 | import AdminBadge from './AdminBadge'; 5 | 6 | export default async function AdminTagBadge({ 7 | tag, 8 | count, 9 | hideBadge, 10 | }: { 11 | tag: string, 12 | count: number, 13 | hideBadge?: boolean, 14 | }) { 15 | return ( 16 | 20 | : } 21 | count={count} 22 | hideBadge={hideBadge} 23 | /> 24 | ); 25 | } -------------------------------------------------------------------------------- /src/recipe/RecipeModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Modal from '@/components/Modal'; 4 | import { useAppState } from '@/app/AppState'; 5 | import PhotoRecipeOverlay from './PhotoRecipeOverlay'; 6 | 7 | export default function ShareModals() { 8 | const { 9 | recipeModalProps, 10 | setRecipeModalProps, 11 | } = useAppState(); 12 | 13 | if (recipeModalProps) { 14 | return setRecipeModalProps?.(undefined)} 16 | container={false} 17 | > 18 | setRecipeModalProps?.(undefined), 21 | isOnPhoto: false, 22 | }}/> 23 | ; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/icons/IconLock.tsx: -------------------------------------------------------------------------------- 1 | import { IconBaseProps } from 'react-icons'; 2 | import { BiLockAlt, BiLockOpenAlt } from 'react-icons/bi'; 3 | import { FaLock, FaLockOpen } from 'react-icons/fa'; 4 | import { FiLock } from 'react-icons/fi'; 5 | 6 | export default function IconLock({ 7 | solid, 8 | narrow, 9 | open, 10 | ...props 11 | }: IconBaseProps & { solid?: boolean, narrow?: boolean, open?: boolean }) { 12 | if (solid) { 13 | return open ? : ; 14 | } else if (narrow) { 15 | return open ? : ; 16 | } else { 17 | return open ? : ; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/admin/AdminTable.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx/lite'; 2 | import { ReactNode } from 'react'; 3 | 4 | export default function AdminTable ({ 5 | title, 6 | children, 7 | }: { 8 | title?: string, 9 | children: ReactNode, 10 | }) { 11 | return
12 | {title && 13 |
14 | {title} 15 |
} 16 | {/* py-[1px] fixes Safari vertical scroll bug */} 17 |
18 |
23 | {children} 24 |
25 |
26 |
; 27 | } 28 | -------------------------------------------------------------------------------- /src/utility/size.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_ASPECT_RATIO = 3.0 / 2.0; 2 | 3 | export const getDimensionsFromSize = ( 4 | size: number, 5 | _aspectRatio?: string | number, 6 | ): { 7 | width: number 8 | height: number 9 | aspectRatio: number 10 | } => { 11 | const aspectRatio = typeof _aspectRatio === 'string' 12 | ? parseFloat(_aspectRatio) 13 | : _aspectRatio || DEFAULT_ASPECT_RATIO; 14 | 15 | let width = size; 16 | let height = size; 17 | 18 | if (aspectRatio > 1) { 19 | height = size / aspectRatio; 20 | } else if (aspectRatio < 1) { 21 | width = size * aspectRatio; 22 | } 23 | 24 | return { 25 | width: Math.round(width), 26 | height: Math.round(height), 27 | aspectRatio, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/admin/DeletePhotoButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { deleteConfirmationTextForPhoto, Photo, titleForPhoto } from '@/photo'; 4 | import DeletePhotosButton from './DeletePhotosButton'; 5 | import { ComponentProps } from 'react'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function DeletePhotoButton({ 9 | photo, 10 | ...rest 11 | }: { 12 | photo: Photo 13 | } & ComponentProps) { 14 | const appText = useAppText(); 15 | return ( 16 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/utility/useSupportsHover.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | export default function useSupportsHover() { 4 | const mqlRef = useRef(typeof window !== 'undefined' 5 | ? window.matchMedia('(hover: hover)') 6 | : undefined); 7 | 8 | const [supportsHover, setSupportsHover] = useState(false); 9 | 10 | useEffect(() => { 11 | const mql = mqlRef.current; 12 | if (mql) { 13 | setSupportsHover(mql.matches); 14 | const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches); 15 | mql.addEventListener('change', listener); 16 | return () => mql?.removeEventListener('change', listener); 17 | } 18 | }, []); 19 | 20 | return supportsHover; 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/icons/IconGrSync.tsx: -------------------------------------------------------------------------------- 1 | export default function IconGrSync({ 2 | className, 3 | }: { 4 | className?: string 5 | }) { 6 | return ( 7 | 17 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/photo/StaggeredOgPhotos.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo } from '@/photo'; 4 | import PhotoOGTile from './PhotoOGTile'; 5 | 6 | export default function StaggeredOgPhotos({ 7 | photos, 8 | onLastPhotoVisible, 9 | }: { 10 | photos: Photo[] 11 | maxConcurrency?: number 12 | onLastPhotoVisible?: () => void 13 | }) { 14 | return ( 15 |
16 | {photos.map((photo, index) => 17 | )} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/utility/html.ts: -------------------------------------------------------------------------------- 1 | import sanitizeHtml from 'sanitize-html'; 2 | 3 | const ALLOWED_FORMATTING_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'a']; 4 | 5 | export const safelyParseFormattedHtml = (text: string) => 6 | sanitizeHtml(text, { 7 | allowedTags: ALLOWED_FORMATTING_TAGS, 8 | allowedSchemes: ['https'], 9 | transformTags: { 10 | a: (tagName, attribs) => { 11 | return { 12 | tagName, 13 | attribs: { 14 | href: attribs.href, 15 | target: '_blank', 16 | }, 17 | }; 18 | }, 19 | }, 20 | }); 21 | 22 | // Matches two or more
or
tags in a row 23 | export const htmlHasBrParagraphBreaks = (text: string) => 24 | /(){2}/i.test(text); 25 | -------------------------------------------------------------------------------- /src/components/icons/IconSearch.tsx: -------------------------------------------------------------------------------- 1 | const INTRINSIC_WIDTH = 28; 2 | const INTRINSIC_HEIGHT = 24; 3 | 4 | export default function IconSearch({ 5 | width = INTRINSIC_WIDTH, 6 | includeTitle = true, 7 | }: { 8 | width?: number; 9 | includeTitle?: boolean; 10 | }) { 11 | return ( 12 | 20 | {includeTitle && Search ⌘K} 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/recents/RecentsShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForRecents } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import RecentsOGTile from './RecentsOGTile'; 5 | import { useAppText } from '@/i18n/state/client'; 6 | 7 | export default function RecentsShareModal({ 8 | photos, 9 | count, 10 | dateRange, 11 | }: PhotoSetAttributes) { 12 | const appText = useAppText(); 13 | return ( 14 | 19 | 20 | 21 | ); 22 | } -------------------------------------------------------------------------------- /src/admin/config/AdminAppConfigurationServer.tsx: -------------------------------------------------------------------------------- 1 | import AdminAppConfigurationClient from './AdminAppConfigurationClient'; 2 | import { APP_CONFIGURATION } from '@/app/config'; 3 | import { testConnectionsAction } from '@/admin/actions'; 4 | import { generateAuthSecret } from '@/auth'; 5 | 6 | export default async function AdminAppConfigurationServer({ 7 | simplifiedView, 8 | }: { 9 | simplifiedView?: boolean 10 | }) { 11 | const connectionErrors = await testConnectionsAction().catch(() => ({})); 12 | 13 | const secret = await generateAuthSecret(); 14 | 15 | return ( 16 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/photo/visibility/PhotoVisibilityIcon.tsx: -------------------------------------------------------------------------------- 1 | import IconLock from '@/components/icons/IconLock'; 2 | import { Photo } from '..'; 3 | import IconHidden from '@/components/icons/IconHidden'; 4 | import { EXCLUDE_DESCRIPTION, PRIVATE_DESCRIPTION } from '.'; 5 | import Tooltip from '@/components/Tooltip'; 6 | 7 | export default function PhotoVisibilityIcon({ 8 | photo, 9 | }: { 10 | photo: Photo 11 | }) { 12 | return photo.hidden 13 | ? 14 | 15 | 16 | : photo.excludeFromFeeds 17 | ? 18 | 19 | 20 | : null; 21 | } 22 | -------------------------------------------------------------------------------- /src/toast/ToasterWithThemes.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { clsx } from 'clsx/lite'; 4 | import { useTheme } from 'next-themes'; 5 | import { Toaster } from 'sonner'; 6 | 7 | export default function ToasterWithThemes() { 8 | const { resolvedTheme } = useTheme(); 9 | return ( 10 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/cmdk/CommandK.tsx: -------------------------------------------------------------------------------- 1 | import CommandKClient from './CommandKClient'; 2 | import { getPhotosMetaCached } from '@/photo/cache'; 3 | import { photoQuantityText } from '@/photo'; 4 | import { getDataForCategoriesCached } from '@/category/cache'; 5 | import { getAppText } from '@/i18n/state/server'; 6 | 7 | export default async function CommandK() { 8 | const [ 9 | count, 10 | categories, 11 | ] = await Promise.all([ 12 | getPhotosMetaCached() 13 | .then(({ count }) => count) 14 | .catch(() => 0), 15 | getDataForCategoriesCached(), 16 | ]); 17 | 18 | const appText = await getAppText(); 19 | 20 | return ( 21 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/LinkWithLoaderBackground.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { ComponentProps } from 'react'; 3 | import LinkWithStatus from './LinkWithStatus'; 4 | 5 | export default function LinkWithLoaderBackground({ 6 | className, 7 | loadingClassName, 8 | offsetPadding, 9 | ...props 10 | }: ComponentProps & { 11 | offsetPadding?: boolean 12 | }) { 13 | return ( 14 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/utility/useVisibility.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function useVisibility({ 4 | ref, 5 | onVisible, 6 | onHidden, 7 | }: { 8 | ref: React.RefObject, 9 | onVisible?: () => void, 10 | onHidden?: () => void, 11 | }) { 12 | useEffect(() => { 13 | if (ref.current && (onVisible || onHidden)) { 14 | const observer = new IntersectionObserver(e => { 15 | if (e[0].isIntersecting) { 16 | onVisible?.(); 17 | } else { 18 | onHidden?.(); 19 | } 20 | }, { 21 | root: null, 22 | threshold: 0, 23 | }); 24 | observer.observe(ref.current); 25 | return () => observer.disconnect(); 26 | } 27 | }, [ref, onVisible, onHidden]); 28 | } 29 | -------------------------------------------------------------------------------- /src/photo/PhotoGridPage.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | import PhotoGridPageClient from './PhotoGridPageClient'; 3 | import { 4 | htmlHasBrParagraphBreaks, 5 | safelyParseFormattedHtml, 6 | } from '@/utility/html'; 7 | import { PAGE_ABOUT } from '@/app/config'; 8 | 9 | export default function PhotoGridPage( 10 | props: ComponentProps, 11 | ) { 12 | const aboutTextSafelyParsedHtml = PAGE_ABOUT 13 | ? safelyParseFormattedHtml(PAGE_ABOUT) 14 | : undefined; 15 | const aboutTextHasBrParagraphBreaks = PAGE_ABOUT 16 | ? htmlHasBrParagraphBreaks(PAGE_ABOUT) 17 | : false; 18 | 19 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/FormWithConfirm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode } from 'react'; 4 | 5 | export default function FormWithConfirm({ 6 | action, 7 | confirmText, 8 | onSubmit, 9 | className, 10 | children, 11 | }: { 12 | action: (formData: FormData) => void 13 | confirmText?: string 14 | onSubmit?: () => void 15 | className?: string 16 | children: ReactNode 17 | }) { 18 | return ( 19 |
{ 22 | if (!confirmText || confirm(confirmText)) { 23 | e.currentTarget.requestSubmit(); 24 | onSubmit?.(); 25 | } else { 26 | e.preventDefault(); 27 | } 28 | }} 29 | className={className} 30 | > 31 | {children} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/year/YearShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForYear } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import YearOGTile from './YearOGTile'; 5 | import { useAppText } from '@/i18n/state/client'; 6 | 7 | export default function YearShareModal({ 8 | year, 9 | photos, 10 | count, 11 | dateRange, 12 | }: { 13 | year: string 14 | } & PhotoSetAttributes) { 15 | const appText = useAppText(); 16 | return ( 17 | 22 | 23 | 24 | ); 25 | } -------------------------------------------------------------------------------- /src/recents/RecentsOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import RecentsHeader from './RecentsHeader'; 3 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 4 | 5 | export default function RecentsOverview({ 6 | photos, 7 | count, 8 | dateRange, 9 | animateOnFirstLoadOnly, 10 | }: { 11 | photos: Photo[], 12 | count: number, 13 | dateRange?: PhotoDateRangePostgres, 14 | animateOnFirstLoadOnly?: boolean, 15 | }) { 16 | return ( 17 | , 27 | animateOnFirstLoadOnly, 28 | }} /> 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ScoreCard.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { ReactNode } from 'react'; 3 | 4 | export default function ScoreCard({ 5 | title, 6 | children, 7 | className, 8 | }: { 9 | title?: ReactNode, 10 | children: ReactNode, 11 | className?: string, 12 | }) { 13 | return ( 14 |
15 | {title && 16 |
21 | {title} 22 |
} 23 |
27 | {children} 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/tag/TagOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import TagHeader from './TagHeader'; 3 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 4 | 5 | export default function TagOverview({ 6 | tag, 7 | photos, 8 | count, 9 | dateRange, 10 | animateOnFirstLoadOnly, 11 | }: { 12 | tag: string, 13 | photos: Photo[], 14 | count: number, 15 | dateRange?: PhotoDateRangePostgres, 16 | animateOnFirstLoadOnly?: boolean, 17 | }) { 18 | return ( 19 | , 30 | animateOnFirstLoadOnly, 31 | }} /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/tag/TagShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForTag } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import TagOGTile from './TagOGTile'; 5 | import { formatTag, shareTextForTag } from '.'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function TagShareModal({ 9 | tag, 10 | photos, 11 | count, 12 | dateRange, 13 | }: { 14 | tag: string 15 | } & PhotoSetAttributes) { 16 | const appText = useAppText(); 17 | return ( 18 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/photo/StaggeredOgPhotosInfinite.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PATH_OG } from '@/app/path'; 4 | import InfinitePhotoScroll from './InfinitePhotoScroll'; 5 | import StaggeredOgPhotos from './StaggeredOgPhotos'; 6 | 7 | export default function StaggeredOgPhotosInfinite({ 8 | initialOffset, 9 | itemsPerPage, 10 | }: { 11 | initialOffset: number 12 | itemsPerPage: number 13 | }) { 14 | return ( 15 | 20 | {({ key, photos, onLastPhotoVisible }) => 21 | } 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/tag/PhotoPrivate.tsx: -------------------------------------------------------------------------------- 1 | import { TAG_PRIVATE } from '.'; 2 | import { pathForTag } from '@/app/path'; 3 | import EntityLink, { 4 | EntityLinkExternalProps, 5 | } from '@/components/entity/EntityLink'; 6 | import IconLock from '@/components/icons/IconLock'; 7 | 8 | export default function PhotoPrivate( 9 | // Prevent hover behavior 10 | props: Omit, 11 | ) { 12 | return ( 13 | } 22 | iconBadgeEnd={} 27 | /> 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/year/meta.ts: -------------------------------------------------------------------------------- 1 | import { descriptionForPhotoSet, Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import { AppTextState } from '@/i18n/state'; 3 | import { absolutePathForYear, absolutePathForYearImage } from '@/app/path'; 4 | 5 | export const generateMetaForYear = ( 6 | year: string, 7 | photos: Photo[], 8 | appText: AppTextState, 9 | count?: number, 10 | _dateRange?: PhotoDateRangePostgres, 11 | ) => { 12 | const title = appText.category.yearTitle(year); 13 | const description = descriptionForPhotoSet( 14 | photos, 15 | appText, 16 | undefined, 17 | undefined, 18 | count, 19 | ); 20 | const url = absolutePathForYear(year); 21 | const images = absolutePathForYearImage(year); 22 | 23 | return { 24 | title, 25 | description, 26 | url, 27 | images, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/admin/config/AdminAppConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { APP_CONFIGURATION } from '@/app/config'; 3 | import AdminAppConfigurationClient from './AdminAppConfigurationClient'; 4 | import AdminAppConfigurationServer from './AdminAppConfigurationServer'; 5 | import { generateAuthSecret } from '@/auth'; 6 | 7 | export default async function AdminAppConfiguration({ 8 | simplifiedView, 9 | }: { 10 | simplifiedView?: boolean 11 | }) { 12 | const secret = await generateAuthSecret(); 13 | return ( 14 | }> 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/recents/meta.ts: -------------------------------------------------------------------------------- 1 | import { descriptionForPhotoSet, Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import { AppTextState } from '@/i18n/state'; 3 | import { 4 | absolutePathForRecents, 5 | absolutePathForRecentsImage, 6 | } from '@/app/path'; 7 | 8 | export const generateMetaForRecents = ( 9 | photos: Photo[], 10 | appText: AppTextState, 11 | count?: number, 12 | _dateRange?: PhotoDateRangePostgres, 13 | ) => { 14 | const title = appText.category.recentTitle; 15 | const description = descriptionForPhotoSet( 16 | photos, 17 | appText, 18 | undefined, 19 | undefined, 20 | count, 21 | ); 22 | const url = absolutePathForRecents(); 23 | const images = absolutePathForRecentsImage(); 24 | 25 | return { 26 | title, 27 | description, 28 | url, 29 | images, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/year/YearOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import YearHeader from './YearHeader'; 3 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 4 | 5 | export default function YearOverview({ 6 | year, 7 | photos, 8 | count, 9 | dateRange, 10 | animateOnFirstLoadOnly, 11 | }: { 12 | year: string, 13 | photos: Photo[], 14 | count: number, 15 | dateRange?: PhotoDateRangePostgres, 16 | animateOnFirstLoadOnly?: boolean, 17 | }) { 18 | return ( 19 | , 30 | animateOnFirstLoadOnly, 31 | }} /> 32 | ); 33 | } -------------------------------------------------------------------------------- /__tests__/html.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | htmlHasBrParagraphBreaks, 3 | safelyParseFormattedHtml, 4 | } from '@/utility/html'; 5 | 6 | describe('HTML', () => { 7 | it('safely parses', () => { 8 | expect(safelyParseFormattedHtml('

TEXT

')).toBe('TEXT'); 9 | expect(safelyParseFormattedHtml('TEXT')).toBe('TEXT'); 10 | }); 11 | it('detects br-style paragraph breaks', () => { 12 | expect(htmlHasBrParagraphBreaks('TEXT

')).toBeTruthy(); 13 | expect(htmlHasBrParagraphBreaks('TEXT

')).toBeTruthy(); 14 | expect(htmlHasBrParagraphBreaks('TEXT

')).toBeTruthy(); 15 | expect(htmlHasBrParagraphBreaks('TEXT')).toBeFalsy(); 16 | expect(htmlHasBrParagraphBreaks('TEXT
')).toBeFalsy(); 17 | expect(htmlHasBrParagraphBreaks('TEXT
')).toBeFalsy(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /app/admin/configuration/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminAppConfiguration from '@/admin/config/AdminAppConfiguration'; 2 | import AdminAppConfigurationSidebar from 3 | '@/admin/config/AdminAppConfigurationSidebar'; 4 | import AdminInfoPage from '@/admin/AdminInfoPage'; 5 | import { APP_CONFIGURATION } from '@/app/config'; 6 | import { Suspense } from 'react'; 7 | 8 | export default function AdminAppConfigurationPage() { 9 | const { areInternalToolsEnabled } = APP_CONFIGURATION; 10 | return ( 11 | 14 | 17 | } 18 | > 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/album/cache.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAlbumFromSlug, 3 | getAlbumsWithMeta, 4 | getAlbumTitlesForPhoto, 5 | getTagsForAlbum, 6 | } from '@/album/query'; 7 | import { KEY_ALBUMS, KEY_PHOTOS } from '@/cache'; 8 | import { unstable_cache } from 'next/cache'; 9 | 10 | export const getAlbumFromSlugCached = 11 | unstable_cache( 12 | getAlbumFromSlug, 13 | [KEY_PHOTOS, KEY_ALBUMS], 14 | ); 15 | 16 | export const getAlbumTitlesForPhotoCached = 17 | unstable_cache( 18 | getAlbumTitlesForPhoto, 19 | [KEY_PHOTOS, KEY_ALBUMS], 20 | ); 21 | 22 | export const getAlbumsWithMetaCached = 23 | unstable_cache( 24 | getAlbumsWithMeta, 25 | [KEY_PHOTOS, KEY_ALBUMS], 26 | ); 27 | 28 | export const getTagsForAlbumCached = 29 | unstable_cache( 30 | getTagsForAlbum, 31 | [KEY_PHOTOS, KEY_ALBUMS], 32 | ); 33 | -------------------------------------------------------------------------------- /src/film/FilmOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import FilmHeader from './FilmHeader'; 3 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 4 | 5 | export default function FilmOverview({ 6 | film, 7 | photos, 8 | count, 9 | dateRange, 10 | animateOnFirstLoadOnly, 11 | }: { 12 | film: string, 13 | photos: Photo[], 14 | count: number, 15 | dateRange?: PhotoDateRangePostgres, 16 | animateOnFirstLoadOnly?: boolean, 17 | }) { 18 | return ( 19 | , 30 | animateOnFirstLoadOnly, 31 | }} /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/album/AlbumShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForAlbum } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import { useAppText } from '@/i18n/state/client'; 5 | import AlbumOGTile from '@/tag/AlbumOGTile'; 6 | import { Album, shareTextForAlbum } from '.'; 7 | 8 | export default function AlbumShareModal({ 9 | album, 10 | photos, 11 | count, 12 | dateRange, 13 | }: { 14 | album: Album 15 | } & PhotoSetAttributes) { 16 | const appText = useAppText(); 17 | return ( 18 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/film/FilmShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForFilm } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import FilmOGTile from './FilmOGTile'; 5 | import { labelForFilm, shareTextForFilm } from '.'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function FilmShareModal({ 9 | film, 10 | photos, 11 | count, 12 | dateRange, 13 | }: { 14 | film: string 15 | } & PhotoSetAttributes) { 16 | const appText = useAppText(); 17 | return ( 18 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/recipe/RecipeOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 3 | import RecipeHeader from './RecipeHeader'; 4 | 5 | export default function RecipeOverview({ 6 | recipe, 7 | photos, 8 | count, 9 | dateRange, 10 | animateOnFirstLoadOnly, 11 | }: { 12 | recipe: string, 13 | photos: Photo[], 14 | count: number, 15 | dateRange?: PhotoDateRangePostgres, 16 | animateOnFirstLoadOnly?: boolean, 17 | }) { 18 | return ( 19 | , 30 | animateOnFirstLoadOnly, 31 | }} /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/utility/exif-format.ts: -------------------------------------------------------------------------------- 1 | import { formatNumberToFraction, roundToString } from './number'; 2 | 3 | export const formatAperture = (aperture?: number) => 4 | aperture 5 | ? `ƒ/${roundToString(aperture)}` 6 | : undefined; 7 | 8 | export const formatIso = (iso?: number) => 9 | iso ? `ISO ${iso}` : undefined; 10 | 11 | export const formatExposureTime = (exposureTime = 0) => 12 | exposureTime > 0 13 | ? exposureTime < 1 14 | ? `1/${Math.floor(1 / exposureTime)}s` 15 | : `${exposureTime}s` 16 | : undefined; 17 | 18 | export const formatExposureCompensation = (exposureCompensation?: number) => { 19 | if ( 20 | exposureCompensation && 21 | Math.abs(exposureCompensation) > 0.01 22 | ) { 23 | return `${formatNumberToFraction(exposureCompensation)}ev`; 24 | } else { 25 | return undefined; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/photo/PhotoOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Photo, 5 | descriptionForPhoto, 6 | titleForPhoto, 7 | } from '@/photo'; 8 | import { PhotoSetCategory } from '../category'; 9 | import { pathForPhoto, pathForPhotoImage } from '@/app/path'; 10 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 11 | 12 | export default function PhotoOGTile({ 13 | photo, 14 | riseOnHover, 15 | retryTime, 16 | onVisible, 17 | ...categories 18 | }: { 19 | photo: Photo 20 | } & PhotoSetCategory & OGTilePropsCore) { 21 | return ( 22 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/year/PhotoYear.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { pathForYear } from '@/app/path'; 4 | import useCategoryCounts from '@/category/useCategoryCounts'; 5 | import EntityLink, { EntityLinkExternalProps } from 6 | '@/components/entity/EntityLink'; 7 | import IconYear from '@/components/icons/IconYear'; 8 | 9 | export default function PhotoYear({ 10 | year, 11 | ...props 12 | }: { 13 | year: string 14 | } & EntityLinkExternalProps) { 15 | const { getYearsCount } = useCategoryCounts(); 16 | return ( 17 | } 26 | hoverCount={props.hoverCount ?? getYearsCount(year)} 27 | /> 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/focal/FocalLengthOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 3 | import FocalLengthHeader from './FocalLengthHeader'; 4 | 5 | export default function FocalLengthOverview({ 6 | focal, 7 | photos, 8 | count, 9 | dateRange, 10 | animateOnFirstLoadOnly, 11 | }: { 12 | focal: number, 13 | photos: Photo[], 14 | count: number, 15 | dateRange?: PhotoDateRangePostgres, 16 | animateOnFirstLoadOnly?: boolean, 17 | }) { 18 | return ( 19 | , 30 | animateOnFirstLoadOnly, 31 | }} /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/admin/AdminEmptyState.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { ReactNode } from 'react'; 3 | import { IoInformationCircleOutline } from 'react-icons/io5'; 4 | 5 | export default function AdminEmptyState({ 6 | icon, 7 | children, 8 | includeContainer = true, 9 | }: { 10 | icon?: ReactNode 11 | children: ReactNode 12 | includeContainer?: boolean 13 | }) { 14 | return ( 15 |
19 |
24 | {icon ?? } 25 |
26 | {children} 27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /__tests__/string.test.ts: -------------------------------------------------------------------------------- 1 | import { parameterize, depluralize } from '@/utility/string'; 2 | 3 | describe('String', () => { 4 | it('parameterizes', () => { 5 | expect(parameterize('my-tag')).toBe('my-tag'); 6 | expect(parameterize('my tag')).toBe('my-tag'); 7 | expect(parameterize('My Tag')).toBe('my-tag'); 8 | expect(parameterize('my_tag')).toBe('my-tag'); 9 | expect(parameterize('person\'s tag')).toBe('persons-tag'); 10 | expect(parameterize('"person\'s tag"')).toBe('persons-tag'); 11 | expect(parameterize('宿宿宿宿')).toBe('宿宿宿宿'); 12 | }); 13 | it('depluralizes', () => { 14 | expect(depluralize('lenses')).toBe('lens'); 15 | expect(depluralize('cameras')).toBe('camera'); 16 | expect(depluralize('tags')).toBe('tag'); 17 | expect(depluralize('recipes')).toBe('recipe'); 18 | expect(depluralize('films')).toBe('film'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/lens/LensShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForLens } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import { formatLensText, Lens } from '.'; 5 | import { shareTextForLens } from './meta'; 6 | import LensOGTile from './LensOGTile'; 7 | import { useAppText } from '@/i18n/state/client'; 8 | 9 | export default function LensShareModal({ 10 | lens, 11 | photos, 12 | count, 13 | dateRange, 14 | }: { 15 | lens: Lens 16 | } & PhotoSetAttributes) { 17 | const appText = useAppText(); 18 | return ( 19 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/lens/LensOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import { Lens, createLensKey } from '.'; 3 | import LensHeader from './LensHeader'; 4 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 5 | 6 | export default function LensOverview({ 7 | lens, 8 | photos, 9 | count, 10 | dateRange, 11 | animateOnFirstLoadOnly, 12 | }: { 13 | lens: Lens, 14 | photos: Photo[], 15 | count: number, 16 | dateRange?: PhotoDateRangePostgres, 17 | animateOnFirstLoadOnly?: boolean, 18 | }) { 19 | return ( 20 | , 32 | }} /> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/swr/index.ts: -------------------------------------------------------------------------------- 1 | export const SWR_KEYS = { 2 | GET_AUTH: 'GET_AUTH', 3 | GET_ADMIN_DATA: 'GET_ADMIN_DATA', 4 | GET_COUNTS_FOR_CATEGORIES: 'GET_COUNTS_FOR_CATEGORIES', 5 | SHARED_HOVER: 'SHARED_HOVER', 6 | INFINITE_PHOTO_SCROLL: 'INFINITE_PHOTO_SCROLL', 7 | } as const; 8 | 9 | const KEYS_THAT_CAN_BE_PURGED = [ 10 | SWR_KEYS.SHARED_HOVER, 11 | SWR_KEYS.INFINITE_PHOTO_SCROLL, 12 | ] as const; 13 | 14 | const KEYS_THAT_CAN_BE_PURGED_AND_REVALIDATED = [ 15 | SWR_KEYS.GET_ADMIN_DATA, 16 | SWR_KEYS.GET_COUNTS_FOR_CATEGORIES, 17 | ] as const; 18 | 19 | export type SWRKey = typeof SWR_KEYS[keyof typeof SWR_KEYS]; 20 | 21 | export const canKeyBePurged = (key: string) => 22 | KEYS_THAT_CAN_BE_PURGED.some(k => key.startsWith(k)); 23 | 24 | export const canKeyBePurgedAndRevalidated = (key: string) => 25 | KEYS_THAT_CAN_BE_PURGED_AND_REVALIDATED.some(k => key.startsWith(k)); 26 | -------------------------------------------------------------------------------- /src/platforms/sony.ts: -------------------------------------------------------------------------------- 1 | import { convertNumberToRomanNumeral } from '@/utility/number'; 2 | 3 | export const MAKE_SONY = 'SONY'; 4 | 5 | export const isMakeSony = (make: string) => 6 | make === MAKE_SONY; 7 | 8 | export const formatSonyModel = (model: string) => { 9 | const [ 10 | _, 11 | type, 12 | series, 13 | letter, 14 | version, 15 | modifier, 16 | ] = /^(ILCE|ILME)-([0-9]*)([a-ln-z]*)M*([0-9]*)([a-z]*)/gi.exec(model) ?? []; 17 | const versionNumber = parseInt(version || '0'); 18 | const versionRomanNumeral = versionNumber > 1 && versionNumber < 10 19 | ? ` ${convertNumberToRomanNumeral(versionNumber)}` 20 | : ''; 21 | if (type === 'ILCE' || type === 'ILME') { 22 | return type === 'ILCE' 23 | ? `A${series}${letter}${versionRomanNumeral || version}${modifier}` 24 | : `FX${series}${version}`; 25 | } 26 | return model; 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/utility/useMetaThemeColor.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes'; 2 | import { useEffect } from 'react'; 3 | 4 | export default function useMetaThemeColor({ 5 | colorLight, 6 | colorDark, 7 | }: { 8 | colorLight?: string 9 | colorDark?: string 10 | }) { 11 | const { resolvedTheme } = useTheme(); 12 | 13 | const preferredThemeColor = resolvedTheme === 'light' 14 | ? colorLight 15 | : colorDark; 16 | 17 | useEffect(() => { 18 | if (preferredThemeColor) { 19 | // Temporarily create meta tag for overlays, 20 | // which prevents stale headers on theme changes 21 | const meta = document.createElement('meta'); 22 | meta.name = 'theme-color'; 23 | meta.content = preferredThemeColor; 24 | document.getElementsByTagName('head')[0]?.appendChild(meta); 25 | return () => meta.remove(); 26 | } 27 | }, [preferredThemeColor]); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/shared-hover/state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentProps, 3 | createContext, 4 | Dispatch, 5 | ReactNode, 6 | SetStateAction, 7 | use, 8 | } from 'react'; 9 | import MenuSurface from '../primitives/MenuSurface'; 10 | 11 | export type SharedHoverProps = { 12 | key: string 13 | width: number 14 | height: number 15 | offsetAbove: number 16 | offsetBelow: number 17 | color?: ComponentProps['color'] 18 | } 19 | 20 | export type SharedHoverState = { 21 | showHover?: (trigger: HTMLElement | null, hover: SharedHoverProps) => void 22 | renderHover?: Dispatch> 23 | dismissHover?: (trigger: HTMLElement | null) => void 24 | isHoverBeingShown?: (key: string) => boolean 25 | } 26 | 27 | export const SharedHoverContext = createContext({}); 28 | 29 | export const useSharedHoverState = () => use(SharedHoverContext); 30 | -------------------------------------------------------------------------------- /src/recents/PhotoRecents.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PREFIX_RECENTS } from '@/app/path'; 4 | import EntityLink, { EntityLinkExternalProps } from 5 | '@/components/entity/EntityLink'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | import IconRecents from '@/components/icons/IconRecents'; 8 | import useCategoryCounts from '@/category/useCategoryCounts'; 9 | 10 | export default function PhotoRecents(props: EntityLinkExternalProps) { 11 | const appText = useAppText(); 12 | const { recentsCount } = useCategoryCounts(); 13 | return ( 14 | } 20 | iconBadgeStart={} 21 | hoverCount={props.hoverCount ?? recentsCount} 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/focal/FocalLengthShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForFocalLength } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import FocalLengthOGTile from './FocalLengthOGTile'; 5 | import { formatFocalLength, shareTextFocalLength } from '.'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function FocalLengthShareModal({ 9 | focal, 10 | photos, 11 | count, 12 | dateRange, 13 | }: { 14 | focal: number 15 | } & PhotoSetAttributes) { 16 | const appText = useAppText(); 17 | return ( 18 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/toast/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import { PiWarningBold } from 'react-icons/pi'; 3 | import { FiCheckSquare } from 'react-icons/fi'; 4 | import { toast } from 'sonner'; 5 | import Spinner from '@/components/Spinner'; 6 | 7 | const DEFAULT_DURATION = 4000; 8 | 9 | export const toastSuccess = ( 10 | message: ReactNode, 11 | duration = DEFAULT_DURATION, 12 | ) => toast( 13 | message, { 14 | icon: , 15 | duration, 16 | }, 17 | ); 18 | 19 | export const toastWarning = ( 20 | message: ReactNode, 21 | duration = DEFAULT_DURATION, 22 | ) => toast( 23 | message, { 24 | icon: , 25 | duration, 26 | }, 27 | ); 28 | 29 | export const toastWaiting = ( 30 | message: ReactNode, 31 | duration = Infinity, 32 | ) => toast( 33 | message, { 34 | icon: , 35 | duration, 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /src/camera/CameraOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import { Camera, createCameraKey } from '.'; 3 | import CameraHeader from './CameraHeader'; 4 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 5 | 6 | export default function CameraOverview({ 7 | camera, 8 | photos, 9 | count, 10 | dateRange, 11 | animateOnFirstLoadOnly, 12 | }: { 13 | camera: Camera, 14 | photos: Photo[], 15 | count: number, 16 | dateRange?: PhotoDateRangePostgres, 17 | animateOnFirstLoadOnly?: boolean, 18 | }) { 19 | return ( 20 | , 32 | }} /> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/camera/CameraShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForCamera } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import CameraOGTile from './CameraOGTile'; 5 | import { Camera, formatCameraText } from '.'; 6 | import { shareTextForCamera } from './meta'; 7 | import { useAppText } from '@/i18n/state/client'; 8 | 9 | export default function CameraShareModal({ 10 | camera, 11 | photos, 12 | count, 13 | dateRange, 14 | }: { 15 | camera: Camera 16 | } & PhotoSetAttributes) { 17 | const appText = useAppText(); 18 | return ( 19 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/primitives/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { clsx } from 'clsx/lite'; 3 | import Spinner from '../Spinner'; 4 | 5 | export default function Icon({ 6 | children, 7 | className, 8 | iconClassName, 9 | wide, 10 | loading, 11 | debug, 12 | }: { 13 | children: ReactNode 14 | className?: string 15 | iconClassName?: string 16 | wide?: boolean 17 | loading?: boolean 18 | debug?: boolean, 19 | }) { 20 | return ( 21 | 29 | {loading 30 | ? 31 | : 32 | {children} 33 | } 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/photo/PhotoFullPage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | INFINITE_SCROLL_FULL_MULTIPLE, 3 | Photo, 4 | } from '.'; 5 | import PhotosLarge from './PhotosLarge'; 6 | import PhotosLargeInfinite from './PhotosLargeInfinite'; 7 | import { SortBy } from './sort'; 8 | 9 | export default function PhotoFullPage({ 10 | photos, 11 | photosCount, 12 | sortBy, 13 | sortWithPriority, 14 | }:{ 15 | photos: Photo[] 16 | photosCount: number 17 | sortBy: SortBy 18 | sortWithPriority: boolean 19 | }) { 20 | return ( 21 |
22 | 23 | {photosCount > photos.length && 24 | } 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Let us know about an issue 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the issue is. 12 | 13 | **Does your app use the latest code?** 14 | If no, consider syncing your fork. 15 | 16 | **Does your app use custom code?** 17 | If yes, what features/customizations have you introduced? 18 | 19 | **Steps to reproduce** 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 23 | **Screenshots** 24 | Images of videos demonstrating the issue. 25 | 26 | **Live deployment** 27 | If available, url to latest deployment/hosted website. 28 | 29 | **Configuration details** 30 | Paste contents of `/admin/configuration/export.json` here. 31 | 32 | **Device details** 33 | - Device/OS: [e.g. Windows, macOS, iPhone15Pro, Android] 34 | - Browser [e.g. chrome, safari] 35 | - Version [e.g. 22] 36 | -------------------------------------------------------------------------------- /src/focal/PhotoFocalLength.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { pathForFocalLength } from '@/app/path'; 4 | import EntityLink, { 5 | EntityLinkExternalProps, 6 | } from '@/components/entity/EntityLink'; 7 | import { formatFocalLength } from '.'; 8 | import IconFocalLength from '@/components/icons/IconFocalLength'; 9 | import useCategoryCounts from '@/category/useCategoryCounts'; 10 | 11 | export default function PhotoFocalLength({ 12 | focal, 13 | ...props 14 | }: { 15 | focal: number 16 | } & EntityLinkExternalProps) { 17 | const { getFocalLengthCount } = useCategoryCounts(); 18 | return ( 19 | } 25 | hoverCount={props.hoverCount ?? getFocalLengthCount(focal)} 26 | /> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/utility/useElementHeight.ts: -------------------------------------------------------------------------------- 1 | import { useState, RefObject, useEffect } from 'react'; 2 | import { useDebouncedCallback } from 'use-debounce'; 3 | 4 | export default function useElementHeight( 5 | ref: RefObject, 6 | shouldDebounce = true, 7 | ) { 8 | const [height, setHeight] = useState(ref.current?.clientHeight); 9 | 10 | const setHeightDebounced = 11 | useDebouncedCallback(setHeight, 250, { leading: true }); 12 | 13 | useEffect(() => { 14 | if (ref.current) { 15 | const observer = new ResizeObserver(e => { 16 | if (shouldDebounce) { 17 | setHeightDebounced(e[0].contentRect.height); 18 | } else { 19 | setHeight(e[0].contentRect.height); 20 | } 21 | }); 22 | observer.observe(ref.current); 23 | return () => observer.disconnect(); 24 | } 25 | }, [ref, setHeightDebounced, shouldDebounce]); 26 | 27 | return height; 28 | } 29 | -------------------------------------------------------------------------------- /src/utility/cookie.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_PATH = '/'; 2 | 3 | export const storeCookie = ( 4 | name: string, 5 | value: string, 6 | path = DEFAULT_PATH, 7 | maxAge = 63158400, 8 | sameSite = 'Lax', 9 | ) => { 10 | if (typeof document !== 'undefined') { 11 | document.cookie = 12 | `${name}=${value};Path=${path};Max-Age=${maxAge};SameSite=${sameSite}`; 13 | } 14 | }; 15 | 16 | export const getCookie = (name: string) => { 17 | if (typeof document !== 'undefined') { 18 | const cookie: Record = {}; 19 | document.cookie.split(';').forEach(function(el) { 20 | const split = el.split('='); 21 | cookie[split[0].trim()] = split.slice(1).join('='); 22 | }); 23 | return cookie[name]; 24 | } 25 | }; 26 | 27 | export const deleteCookie = (name: string, path = DEFAULT_PATH) => { 28 | if (typeof document !== 'undefined') { 29 | document.cookie = `${name}=;Path=${path};Max-Age=0`; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/feed/programmatic.ts: -------------------------------------------------------------------------------- 1 | import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo'; 2 | import { getOptimizedPhotoUrl } from '@/photo/storage'; 3 | import { NextImageSize } from '@/platforms/next-image'; 4 | 5 | export const FEED_PHOTO_REQUEST_LIMIT = 40; 6 | 7 | export const FEED_PHOTO_WIDTH_SMALL = 200; 8 | export const FEED_PHOTO_WIDTH_MEDIUM = 640; 9 | export const FEED_PHOTO_WIDTH_LARGE = 1200; 10 | 11 | export interface FeedMedia { 12 | url: string 13 | width: number 14 | height: number 15 | } 16 | 17 | export const generateFeedMedia = ( 18 | photo: Photo, 19 | size: NextImageSize, 20 | ): FeedMedia => ({ 21 | url: getOptimizedPhotoUrl({ imageUrl: photo.url, size }), 22 | width: size, 23 | height: Math.round(size / photo.aspectRatio), 24 | }); 25 | 26 | export const getCoreFeedFields = (photo: Photo) => ({ 27 | id: photo.id, 28 | title: titleForPhoto(photo), 29 | description: descriptionForPhoto(photo, true), 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/icons/IconFull.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | const INTRINSIC_WIDTH = 28; 4 | const INTRINSIC_HEIGHT = 24; 5 | 6 | export default function IconFull({ 7 | width = INTRINSIC_WIDTH, 8 | includeTitle = true, 9 | className, 10 | }: { 11 | width?: number 12 | includeTitle?: boolean 13 | className?: string 14 | }) { 15 | return ( 16 | 25 | {includeTitle && Full Frame} 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/album/AlbumOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import PhotoGridContainer from '@/photo/PhotoGridContainer'; 3 | import { Album } from '.'; 4 | import AlbumHeader from './AlbumHeader'; 5 | 6 | export default function AlbumOverview({ 7 | album, 8 | photos, 9 | tags, 10 | count, 11 | dateRange, 12 | animateOnFirstLoadOnly, 13 | }: { 14 | album: Album, 15 | photos: Photo[], 16 | tags: string[], 17 | count: number, 18 | dateRange?: PhotoDateRangePostgres, 19 | animateOnFirstLoadOnly?: boolean, 20 | }) { 21 | return ( 22 | , 35 | animateOnFirstLoadOnly, 36 | }} /> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /__tests__/category.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_CATEGORY_KEYS, 3 | parseOrderedCategoriesFromString, 4 | } from '@/category'; 5 | 6 | describe('set', () => { 7 | it('parses from string', () => { 8 | expect(parseOrderedCategoriesFromString()) 9 | .toStrictEqual(DEFAULT_CATEGORY_KEYS); 10 | 11 | expect(parseOrderedCategoriesFromString( 12 | 'cameras,recipes,tags,films,focal-lengths,lenses', 13 | )).toStrictEqual([ 14 | 'cameras', 15 | 'recipes', 16 | 'tags', 17 | 'films', 18 | 'focal-lengths', 19 | 'lenses', 20 | ]); 21 | 22 | expect(parseOrderedCategoriesFromString( 23 | 'cameras, recipes, tags, films', 24 | )).toStrictEqual([ 25 | 'cameras', 26 | 'recipes', 27 | 'tags', 28 | 'films', 29 | ]); 30 | 31 | expect(parseOrderedCategoriesFromString( 32 | 'cameras', 33 | )).toStrictEqual([ 34 | 'cameras', 35 | ]); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/utility/useViewportHeight.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useDebouncedCallback } from 'use-debounce'; 3 | 4 | export default function useViewportHeight( 5 | shouldDebounce = true, 6 | ) { 7 | const [viewportHeight, setViewportHeight] = useState(0); 8 | 9 | const setViewportHeightDebounced = 10 | useDebouncedCallback(setViewportHeight, 100); 11 | 12 | useEffect(() => { 13 | const handleResize = () => { 14 | if (shouldDebounce) { 15 | setViewportHeightDebounced(window.visualViewport?.height ?? 0); 16 | } else { 17 | setViewportHeight(window.visualViewport?.height ?? 0); 18 | } 19 | }; 20 | handleResize(); 21 | window.visualViewport?.addEventListener('resize', handleResize); 22 | return () => 23 | window.visualViewport?.removeEventListener('resize', handleResize); 24 | }, [shouldDebounce, setViewportHeightDebounced]); 25 | 26 | return viewportHeight; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/primitives/MenuSurface.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, RefObject } from 'react'; 2 | import clsx from 'clsx/lite'; 3 | 4 | export default function MenuSurface({ 5 | ref, 6 | children, 7 | className, 8 | color, 9 | }: { 10 | ref?: RefObject 11 | children: ReactNode 12 | className?: string 13 | color?: 'light' | 'dark' | 'frosted' 14 | }) { 15 | return ( 16 |
29 | {children} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/admin/AdminAppInfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from '@/app/AppState'; 2 | import clsx from 'clsx/lite'; 3 | import { LuCog } from 'react-icons/lu'; 4 | import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; 5 | 6 | export default function AdminAppInfoIcon({ 7 | size = 'large', 8 | className, 9 | }: { 10 | size?: 'small' | 'large' 11 | className?: string 12 | }) { 13 | const { insightsIndicatorStatus } = useAppState(); 14 | 15 | return ( 16 | 20 | 25 | {insightsIndicatorStatus && 26 | } 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/tag/PhotoTags.tsx: -------------------------------------------------------------------------------- 1 | import PhotoTag from '@/tag/PhotoTag'; 2 | import { isTagFavs } from '.'; 3 | import PhotoFavs from './PhotoFavs'; 4 | import { EntityLinkExternalProps } from '@/components/entity/EntityLink'; 5 | import { Fragment } from 'react'; 6 | 7 | export default function PhotoTags({ 8 | tags, 9 | tagCounts = {}, 10 | contrast, 11 | prefetch, 12 | }: { 13 | tags: string[] 14 | tagCounts?: Record 15 | } & EntityLinkExternalProps) { 16 | return ( 17 |
18 | {tags.map(tag => 19 | 20 | {isTagFavs(tag) 21 | ? 26 | : } 30 | )} 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/utility/usePrefersReducedMotion.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const MEDIA_QUERY_SELECTOR = '(prefers-reduced-motion: reduce)'; 4 | const MEDIA_QUERY_EVENT = 'change'; 5 | 6 | const safelyGetMediaQuery = () => typeof window !== 'undefined' 7 | ? window.matchMedia(MEDIA_QUERY_SELECTOR) 8 | : undefined; 9 | 10 | const usePrefersReducedMotion = () => { 11 | const [prefersReducedMotion, setPrefersReducedMotion] = useState( 12 | safelyGetMediaQuery()?.matches ?? false, 13 | ); 14 | 15 | useEffect(() => { 16 | const mediaQuery = safelyGetMediaQuery(); 17 | 18 | const listener = () => { 19 | setPrefersReducedMotion(mediaQuery?.matches ?? false); 20 | }; 21 | 22 | mediaQuery?.addEventListener(MEDIA_QUERY_EVENT, listener); 23 | return () => mediaQuery?.removeEventListener(MEDIA_QUERY_EVENT, listener); 24 | }, []); 25 | 26 | return prefersReducedMotion; 27 | }; 28 | 29 | export default usePrefersReducedMotion; 30 | -------------------------------------------------------------------------------- /src/app/HomeImageResponse.tsx: -------------------------------------------------------------------------------- 1 | import { NAV_TITLE } from '@/app/config'; 2 | import { Photo } from '../photo'; 3 | import ImageCaption from '@/image-response/components/ImageCaption'; 4 | import ImageContainer from '@/image-response/components/ImageContainer'; 5 | import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid'; 6 | import { NextImageSize } from '@/platforms/next-image'; 7 | 8 | export default function HomeImageResponse({ 9 | photos, 10 | width, 11 | height, 12 | fontFamily, 13 | }: { 14 | photos: Photo[] 15 | width: NextImageSize 16 | height: number 17 | fontFamily: string 18 | }) { 19 | return ( 20 | 21 | 28 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/recents/RecentsOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres, descriptionForPhotoSet } from '@/photo'; 4 | import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/path'; 5 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function RecentsOGTile({ 9 | photos, 10 | count, 11 | dateRange, 12 | ...props 13 | }: { 14 | photos: Photo[] 15 | count?: number 16 | dateRange?: PhotoDateRangePostgres 17 | } & OGTilePropsCore) { 18 | const appText = useAppText(); 19 | return ( 20 | 34 | ); 35 | } -------------------------------------------------------------------------------- /src/tag/PrivateHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, photoQuantityText } from '@/photo'; 2 | import PhotoHeader from '@/photo/PhotoHeader'; 3 | import PhotoPrivate from './PhotoPrivate'; 4 | import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config'; 5 | import { getAppText } from '@/i18n/state/server'; 6 | 7 | export default async function PrivateHeader({ 8 | photos, 9 | selectedPhoto, 10 | indexNumber, 11 | count, 12 | }: { 13 | photos: Photo[] 14 | selectedPhoto?: Photo 15 | indexNumber?: number 16 | count: number 17 | }) { 18 | const appText = await getAppText(); 19 | return ( 20 | } 23 | entityDescription={photoQuantityText(count, appText, false, false)} 24 | photos={photos} 25 | selectedPhoto={selectedPhoto} 26 | indexNumber={indexNumber} 27 | count={count} 28 | hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED} 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/film/FilmOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import { 5 | pathForFilm, 6 | pathForFilmImage, 7 | } from '@/app/path'; 8 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 9 | import { descriptionForFilmPhotos, titleForFilm } from '.'; 10 | import { useAppText } from '@/i18n/state/client'; 11 | 12 | export default function FilmOGTile({ 13 | film, 14 | photos, 15 | count, 16 | dateRange, 17 | ...props 18 | }: { 19 | film: string 20 | photos: Photo[] 21 | count?: number 22 | dateRange?: PhotoDateRangePostgres 23 | } & OGTilePropsCore) { 24 | const appText = useAppText(); 25 | return ( 26 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/LinkWithIconLoader.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, ReactNode } from 'react'; 2 | import LinkWithStatus from './LinkWithStatus'; 3 | import clsx from 'clsx/lite'; 4 | 5 | export default function LinkWithIconLoader({ 6 | className, 7 | icon, 8 | loader, 9 | ...props 10 | }: Omit, 'children'> & { 11 | icon: ReactNode 12 | loader: ReactNode 13 | }) { 14 | return ( 15 | 19 | {({ isLoading }) => <> 20 | 24 | {icon} 25 | 26 | {isLoading && 30 | {loader} 31 | } 32 | } 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/utility/useHash.ts: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'next/navigation'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | 4 | export default function useHash() { 5 | const [hash, setHash] = useState(''); 6 | 7 | const storeHash = useCallback(() => { 8 | setHash(window.location.hash.replace('#', '')); 9 | }, []); 10 | 11 | useEffect(() => { 12 | window.addEventListener('hashchange', storeHash); 13 | return () => { 14 | window.removeEventListener('hashchange', storeHash); 15 | }; 16 | }, [storeHash]); 17 | 18 | // Needed to capture non-request-initiated hash changes 19 | const params = useSearchParams(); 20 | // eslint-disable-next-line react-hooks/set-state-in-effect 21 | useEffect(storeHash, [params, storeHash]); 22 | 23 | const updateWindowHash = useCallback((hash: string) => { 24 | window.history.replaceState(null, '', `#${hash}`); 25 | }, []); 26 | 27 | return { 28 | hash, 29 | updateHash: updateWindowHash, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/utility/useIsKeyBeingPressed.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const LISTENER_KEYDOWN = 'keydown'; 4 | const LISTENER_KEYUP = 'keyup'; 5 | 6 | export default function useIsKeyBeingPressed(key: string) { 7 | const [isPressed, setIsPressed] = useState(false); 8 | 9 | useEffect(() => { 10 | const handleKeyDown = (e: KeyboardEvent) => { 11 | if (e.key.toLowerCase() === key.toLowerCase()) { 12 | setIsPressed(true); 13 | } 14 | }; 15 | const handleKeyUp = (e: KeyboardEvent) => { 16 | if (e.key.toLowerCase() === key.toLowerCase()) { 17 | setIsPressed(false); 18 | } 19 | }; 20 | window.addEventListener(LISTENER_KEYDOWN, handleKeyDown); 21 | window.addEventListener(LISTENER_KEYUP, handleKeyUp); 22 | return () => { 23 | window.removeEventListener(LISTENER_KEYDOWN, handleKeyDown); 24 | window.removeEventListener(LISTENER_KEYUP, handleKeyUp); 25 | }; 26 | }, [key]); 27 | 28 | return isPressed; 29 | } 30 | -------------------------------------------------------------------------------- /src/year/YearOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres, descriptionForPhotoSet } from '@/photo'; 4 | import { pathForYear, pathForYearImage } from '@/app/path'; 5 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function YearOGTile({ 9 | year, 10 | photos, 11 | count, 12 | dateRange, 13 | ...props 14 | }: { 15 | year: string 16 | photos: Photo[] 17 | count?: number 18 | dateRange?: PhotoDateRangePostgres 19 | } & OGTilePropsCore) { 20 | const appText = useAppText(); 21 | return ( 22 | 36 | ); 37 | } -------------------------------------------------------------------------------- /src/platforms/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from '@upstash/ratelimit'; 2 | import { redis } from './redis'; 3 | 4 | export const checkRateLimitAndThrow = async ({ 5 | identifier, 6 | tokens = 100, 7 | duration = '1h', 8 | }: { 9 | identifier: string 10 | tokens?: number 11 | duration?: Parameters[1] 12 | }) => { 13 | if (redis) { 14 | const limiter = new Ratelimit({ 15 | redis, 16 | limiter: Ratelimit.slidingWindow(tokens, duration), 17 | }); 18 | let success = false; 19 | try { 20 | success = (await limiter.limit(identifier)).success; 21 | } catch (e: any) { 22 | const message = 23 | `Failed to connect to redis rate limiting store ('${identifier}')`; 24 | console.error(message, e); 25 | throw new Error(message); 26 | } 27 | if (!success) { 28 | const message = `'${identifier}' rate limit exceeded`; 29 | console.error(message); 30 | throw new Error(message); 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /app/admin/uploads/page.tsx: -------------------------------------------------------------------------------- 1 | import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; 2 | import AppGrid from '@/components/AppGrid'; 3 | import { getUniqueTagsCached } from '@/photo/cache'; 4 | import { getAlbumsWithMetaCached } from '@/album/cache'; 5 | import AdminUploadsClient from '@/admin/AdminUploadsClient'; 6 | import { redirect } from 'next/navigation'; 7 | import { PATH_ADMIN_PHOTOS } from '@/app/path'; 8 | 9 | export const maxDuration = 60; 10 | 11 | export default async function AdminUploadsPage() { 12 | const urls = await getStorageUploadUrlsNoStore(); 13 | const uniqueAlbums = await getAlbumsWithMetaCached(); 14 | const uniqueTags = await getUniqueTagsCached(); 15 | 16 | if (urls.length === 0) { 17 | redirect(PATH_ADMIN_PHOTOS); 18 | } else { 19 | return ( 20 | } 27 | /> 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/og/all/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | INFINITE_SCROLL_GRID_INITIAL, 3 | INFINITE_SCROLL_GRID_MULTIPLE, 4 | } from '@/photo'; 5 | import { getPhotosCached, getPhotosMetaCached } from '@/photo/cache'; 6 | import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos'; 7 | import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite'; 8 | 9 | export default async function OGPage() { 10 | const [ 11 | photos, 12 | count, 13 | ] = await Promise.all([ 14 | getPhotosCached({ limit: INFINITE_SCROLL_GRID_INITIAL }) 15 | .catch(() => []), 16 | getPhotosMetaCached() 17 | .then(({ count }) => count) 18 | .catch(() => 0), 19 | ]); 20 | 21 | return ( 22 | <> 23 | 24 | {count > photos.length && 25 |
26 | 30 |
} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/lens/PhotoLens.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { pathForLens } from '@/app/path'; 4 | import { Lens, formatLensText } from '.'; 5 | import EntityLink, { 6 | EntityLinkExternalProps, 7 | } from '@/components/entity/EntityLink'; 8 | import IconLens from '@/components/icons/IconLens'; 9 | import useCategoryCounts from '@/category/useCategoryCounts'; 10 | 11 | export default function PhotoLens({ 12 | lens, 13 | longText, 14 | ...props 15 | }: { 16 | lens: Lens 17 | longText?: boolean 18 | } & EntityLinkExternalProps) { 19 | const { getLensCount } = useCategoryCounts(); 20 | return ( 21 | } 31 | hoverCount={props.hoverCount ?? getLensCount(lens)} 32 | /> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/admin/AdminLink.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import Link from 'next/link'; 3 | import { ComponentProps } from 'react'; 4 | import { FiExternalLink } from 'react-icons/fi'; 5 | export default function AdminLink({ 6 | href, 7 | className, 8 | children, 9 | externalIcon, 10 | ...props 11 | }: ComponentProps & { 12 | externalIcon?: boolean 13 | }) { 14 | return ( 15 | <> 16 | 22 | 26 | {children} 27 | 28 | {externalIcon && 29 |   30 | 34 | } 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/icons/IconGrid.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | const INTRINSIC_WIDTH = 28; 4 | const INTRINSIC_HEIGHT = 24; 5 | 6 | export default function IconGrid({ 7 | width = INTRINSIC_WIDTH, 8 | includeTitle = true, 9 | className, 10 | }: { 11 | width?: number 12 | includeTitle?: boolean 13 | className?: string 14 | }) { 15 | return ( 16 | 25 | {includeTitle && Grid} 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/tag/TagOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import { pathForTag, pathForTagImage } from '@/app/path'; 5 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 6 | import { descriptionForTaggedPhotos, titleForTag } from '.'; 7 | import { useAppText } from '@/i18n/state/client'; 8 | 9 | export default function TagOGTile({ 10 | tag, 11 | photos, 12 | count, 13 | dateRange, 14 | ...props 15 | }: { 16 | tag: string 17 | photos: Photo[] 18 | count?: number 19 | dateRange?: PhotoDateRangePostgres 20 | } & OGTilePropsCore) { 21 | const appText = useAppText(); 22 | return ( 23 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/SmallDisclosure.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import { ReactNode, useState } from 'react'; 3 | import { LuChevronRight } from 'react-icons/lu'; 4 | 5 | export default function SmallDisclosure({ 6 | label, 7 | children, 8 | }: { 9 | label: ReactNode 10 | children: ReactNode 11 | }) { 12 | const [isOpen, setIsOpen] = useState(false); 13 | return ( 14 |
15 | 31 | {isOpen && 32 |
33 | {children} 34 |
} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/RepoLink.tsx: -------------------------------------------------------------------------------- 1 | import { TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app/config'; 2 | import { useAppText } from '@/i18n/state/client'; 3 | import { clsx } from 'clsx/lite'; 4 | import Link from 'next/link'; 5 | import { BiLogoGithub } from 'react-icons/bi'; 6 | 7 | export default function RepoLink() { 8 | const { footer } = useAppText(); 9 | 10 | return ( 11 | 12 | 13 | {footer.madeWith} 14 | 15 | 24 | 28 | {TEMPLATE_REPO_NAME} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/lens/LensOGTile.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import { pathForLens, pathForLensImage } from '@/app/path'; 3 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 4 | import { Lens } from '.'; 5 | import { titleForLens, descriptionForLensPhotos } from './meta'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function LensOGTile({ 9 | lens, 10 | photos, 11 | count, 12 | dateRange, 13 | ...props 14 | }: { 15 | lens: Lens 16 | photos: Photo[] 17 | count?: number 18 | dateRange?: PhotoDateRangePostgres 19 | } & OGTilePropsCore) { 20 | const appText = useAppText(); 21 | return ( 22 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/recipe/RecipeOGTile.tsx: -------------------------------------------------------------------------------- 1 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 2 | import { pathForRecipe, pathForRecipeImage } from '@/app/path'; 3 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 4 | import { descriptionForRecipePhotos, titleForRecipe } from '.'; 5 | import { useAppText } from '@/i18n/state/client'; 6 | 7 | export default function RecipeOGTile({ 8 | recipe, 9 | photos, 10 | count, 11 | dateRange, 12 | ...props 13 | }: { 14 | recipe: string 15 | photos: Photo[] 16 | count?: number 17 | dateRange?: PhotoDateRangePostgres 18 | } & OGTilePropsCore) { 19 | const appText = useAppText(); 20 | return ( 21 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/admin/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import LoaderButton from '@/components/primitives/LoaderButton'; 2 | import { clsx } from 'clsx/lite'; 3 | import { ComponentProps } from 'react'; 4 | import { BiTrash } from 'react-icons/bi'; 5 | 6 | export default function DeleteButton({ 7 | className, 8 | ...rest 9 | }: ComponentProps ) { 10 | return ( 11 | } 15 | spinnerColor="text" 16 | className={clsx( 17 | 'text-red-500! dark:text-red-500!', 18 | 'active:bg-red-100/50! dark:active:bg-red-950/50!', 19 | 'disabled:text-red-500/60! dark:disabled:text-red-500/60!', 20 | 'disabled:bg-red-100/50! dark:disabled:bg-red-950/50!', 21 | 'border-red-200! disabled:border-red-200! hover:border-red-300!', 22 | // eslint-disable-next-line max-len 23 | 'dark:border-red-900/75! dark:disabled:border-red-900/75! dark:hover:border-red-900!', 24 | className, 25 | )} 26 | /> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/photo/visibility/FieldsetVisibility.tsx: -------------------------------------------------------------------------------- 1 | import FieldsetWithStatus from '@/components/FieldsetWithStatus'; 2 | import { ComponentProps, Dispatch, SetStateAction } from 'react'; 3 | import { 4 | getVisibilityValue, 5 | updateFormDataWithVisibility, 6 | VISIBILITY_OPTIONS, 7 | VisibilityValue, 8 | } from '.'; 9 | import { PhotoFormData } from '../form'; 10 | 11 | export default function FieldsetVisibility({ 12 | formData, 13 | setFormData, 14 | ...props 15 | }: { 16 | label?: string 17 | formData: Partial 18 | setFormData: Dispatch>> 19 | } & Omit, 'label' | 'value'>) { 20 | return ( 21 | setFormData(data => 27 | updateFormDataWithVisibility( 28 | data, 29 | value as VisibilityValue, 30 | ))} 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/photo/color/PhotoColors.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx/lite'; 2 | import ColorDot from './ColorDot'; 3 | import { PhotoColorData } from './client'; 4 | 5 | export default function PhotoColors({ 6 | className, 7 | classNameDot, 8 | colorData, 9 | }: { 10 | className?: string 11 | classNameDot?: string 12 | colorData?: PhotoColorData 13 | }) { 14 | return colorData 15 | ?
19 | {colorData.ai && 20 | } 25 | 30 | {colorData.colors.map((color, index) => 31 | , 37 | )} 38 |
39 | : null; 40 | } 41 | -------------------------------------------------------------------------------- /src/tag/AlbumOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import { pathForAlbum, pathForAlbumImage } from '@/app/path'; 5 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | import { Album, descriptionForAlbumPhotos, titleForAlbum } from '@/album'; 8 | 9 | export default function AlbumOGTile({ 10 | album, 11 | photos, 12 | count, 13 | dateRange, 14 | ...props 15 | }: { 16 | album: Album 17 | photos: Photo[] 18 | count?: number 19 | dateRange?: PhotoDateRangePostgres 20 | } & OGTilePropsCore) { 21 | const appText = useAppText(); 22 | return ( 23 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/LoaderLink.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, ReactNode } from 'react'; 2 | import LinkWithStatus from './LinkWithStatus'; 3 | import Spinner from './Spinner'; 4 | import clsx from 'clsx/lite'; 5 | 6 | export default function LoaderLink({ 7 | icon, 8 | classNameIcon, 9 | children, 10 | ...props 11 | }: Omit, 'children'> & { 12 | icon: ReactNode 13 | classNameIcon?: string 14 | children?: ReactNode 15 | }) { 16 | return ( 17 | 18 | {({ isLoading }) => 19 | 20 | 25 | {isLoading 26 | ? 27 | : icon} 28 | 29 | {children && 30 | 31 | {children} 32 | } 33 | } 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/css/sonner.css: -------------------------------------------------------------------------------- 1 | /* Align toasts to site breakpoints + margins */ 2 | 3 | @media (max-width: 639px) { 4 | [data-sonner-toaster] { 5 | position: fixed; 6 | --mobile-offset: 12px !important; 7 | --mobile-offset-left: var(--mobile-offset) !important; 8 | --mobile-offset-right: var(--mobile-offset) !important; 9 | right: var(--mobile-offset); 10 | left: var(--mobile-offset); 11 | width: 100% !important; 12 | } 13 | 14 | [data-sonner-toaster] [data-sonner-toast] { 15 | left: 0; 16 | right: 0; 17 | width: calc(100% - var(--mobile-offset) * 2) !important; 18 | } 19 | 20 | [data-sonner-toaster][data-x-position='left'] { 21 | left: var(--mobile-offset); 22 | } 23 | 24 | [data-sonner-toaster][data-y-position='bottom'] { 25 | bottom: 20px !important; 26 | } 27 | 28 | [data-sonner-toaster][data-y-position='top'] { 29 | top: 20px; 30 | } 31 | 32 | [data-sonner-toaster][data-x-position='center'] { 33 | left: var(--mobile-offset); 34 | right: var(--mobile-offset); 35 | transform: none; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/switcher/SwitcherItemMenu.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import MoreMenu from '@/components/more/MoreMenu'; 4 | import { clsx } from 'clsx/lite'; 5 | import { ComponentProps } from 'react'; 6 | 7 | export default function SwitcherItemMenu({ 8 | className, 9 | classNameButton, 10 | classNameButtonOpen, 11 | ...props 12 | }: ComponentProps) { 13 | return ( 14 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /__tests__/photo.test.ts: -------------------------------------------------------------------------------- 1 | import { descriptionForPhoto, Photo } from '@/photo'; 2 | 3 | const PHOTO: Partial = { 4 | takenAt: new Date('2025-01-01 12:00:00'), 5 | }; 6 | 7 | const PHOTO_SEMANTIC: Partial = { 8 | ...PHOTO, 9 | semanticDescription: 'Semantic Description', 10 | }; 11 | 12 | const PHOTO_CAPTION: Partial = { 13 | ...PHOTO_SEMANTIC, 14 | caption: 'Caption', 15 | }; 16 | 17 | describe('Should generate photo description', () => { 18 | it('with caption', () => { 19 | expect(descriptionForPhoto(PHOTO_CAPTION as Photo)) 20 | .toBe('Caption'); 21 | }); 22 | it('with semantic description (disabled)', () => { 23 | expect(descriptionForPhoto(PHOTO_SEMANTIC as Photo)) 24 | .toBe('01 JAN 2025 12:00PM'); 25 | }); 26 | it('with semantic description (enabled)', () => { 27 | expect(descriptionForPhoto(PHOTO_SEMANTIC as Photo, true)) 28 | .toBe('Semantic Description'); 29 | }); 30 | it('with date', () => { 31 | expect(descriptionForPhoto(PHOTO as Photo)) 32 | .toBe('01 JAN 2025 12:00PM'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/platforms/safe-photo-image-response.ts: -------------------------------------------------------------------------------- 1 | import { Photo } from '@/photo'; 2 | import { ImageResponse } from 'next/og'; 3 | import { JSX } from 'react'; 4 | import { IS_PREVIEW } from '@/app/config'; 5 | import { getOptimizedPhotoUrl } from '@/photo/storage'; 6 | 7 | const isNextImageReadyBasedOnPhotos = async ( 8 | photos: Photo[], 9 | ): Promise => 10 | photos.length > 0 && 11 | fetch(getOptimizedPhotoUrl({ 12 | imageUrl: photos[0].url, 13 | size: 640, 14 | addBypassSecret: IS_PREVIEW, 15 | })) 16 | .then(response => response.ok) 17 | .catch(() => false); 18 | 19 | export const safePhotoImageResponse = async ( 20 | photos: Photo[], 21 | jsx: (isNextImageReady: boolean) => JSX.Element, 22 | options: ConstructorParameters[1], 23 | ) => { 24 | // Make sure next/image can be reached from absolute urls, 25 | // which may not exist on first pre-render 26 | const isNextImageReady = await isNextImageReadyBasedOnPhotos(photos); 27 | 28 | return new ImageResponse( 29 | jsx(isNextImageReady), 30 | options, 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/utility/useScrollDirection.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useScrollDirection() { 4 | const [scrollInfo, setScrollInfo] = useState({ 5 | scrollDirection: 'down' as 'up' | 'down', 6 | scrollY: 0, 7 | }); 8 | 9 | useEffect(() => { 10 | const handleScroll = () => { 11 | const scrollY = window.scrollY; 12 | const pageHeight = ( 13 | document.documentElement.scrollHeight - 14 | window.innerHeight 15 | ); 16 | setScrollInfo(prev => { 17 | let scrollDirection = prev.scrollDirection; 18 | if (scrollY !== prev.scrollY) { 19 | scrollDirection = ( 20 | scrollY > prev.scrollY || 21 | prev.scrollY > pageHeight 22 | ) ? 'down' : 'up'; 23 | } 24 | return { 25 | scrollDirection, 26 | scrollY, 27 | }; 28 | }); 29 | }; 30 | window.addEventListener('scroll', handleScroll); 31 | return () => window.removeEventListener('scroll', handleScroll); 32 | }, []); 33 | 34 | return scrollInfo; 35 | } 36 | -------------------------------------------------------------------------------- /src/admin/AdminRecipeBadge.tsx: -------------------------------------------------------------------------------- 1 | import { photoLabelForCount } from '@/photo'; 2 | import { clsx } from 'clsx/lite'; 3 | import Badge from '@/components/Badge'; 4 | import PhotoRecipe from '@/recipe/PhotoRecipe'; 5 | import { getAppText } from '@/i18n/state/server'; 6 | 7 | export default async function AdminRecipeBadge({ 8 | recipe, 9 | count, 10 | hideBadge, 11 | }: { 12 | recipe: string, 13 | count: number, 14 | hideBadge?: boolean, 15 | }) { 16 | const appText = await getAppText(); 17 | 18 | const renderBadgeContent = () => 19 |
22 | 23 |
24 | {count} 25 | 26 |   27 | {photoLabelForCount(count, appText)} 28 | 29 |
30 |
; 31 | 32 | return ( 33 | hideBadge 34 | ? renderBadgeContent() 35 | : {renderBadgeContent()} 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/tag/PhotoFavs.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { TAG_FAVS } from '.'; 4 | import { pathForTag } from '@/app/path'; 5 | import useCategoryCounts from '@/category/useCategoryCounts'; 6 | import EntityLink, { 7 | EntityLinkExternalProps, 8 | } from '@/components/entity/EntityLink'; 9 | import IconFavs from '@/components/icons/IconFavs'; 10 | 11 | export default function PhotoFavs({ 12 | badgeIconFirst, 13 | ...props 14 | }: EntityLinkExternalProps & { badgeIconFirst?: boolean }) { 15 | const { getTagCount } = useCategoryCounts(); 16 | return ( 17 | } 27 | iconBadgeEnd={!badgeIconFirst && } 32 | hoverCount={props.hoverCount ?? getTagCount(TAG_FAVS)} 33 | /> 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/photo/UpdateBlurDataButton.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx/lite'; 2 | import { FiRotateCcw } from 'react-icons/fi'; 3 | import { getImageBlurAction } from './actions'; 4 | import { useState } from 'react'; 5 | import Spinner from '@/components/Spinner'; 6 | 7 | export default function UpdateBlurDataButton({ 8 | photoUrl, 9 | onUpdatedBlurData, 10 | }: { 11 | photoUrl?: string 12 | onUpdatedBlurData: (blurData: string) => void 13 | }) { 14 | const [isLoading, setIsLoading] = useState(false); 15 | 16 | return ( 17 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/album/FieldsetAlbum.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, useEffect, useRef } from 'react'; 2 | import FieldsetWithStatus from '@/components/FieldsetWithStatus'; 3 | import { Albums } from '.'; 4 | import { convertAlbumsToAnnotatedTags } from './form'; 5 | 6 | export default function FieldsetAlbum({ 7 | albumOptions, 8 | label, 9 | openOnLoad, 10 | ...props 11 | }: { 12 | albumOptions: Albums 13 | label?: string 14 | openOnLoad?: boolean 15 | } & Omit, 'label'>) { 16 | const ref = useRef(null); 17 | 18 | useEffect(() => { 19 | if (openOnLoad) { 20 | const timeout = setTimeout(() => { 21 | ref.current?.querySelectorAll('input')[0]?.focus(); 22 | }, 100); 23 | return () => clearTimeout(timeout); 24 | } 25 | }, [openOnLoad]); 26 | 27 | return ( 28 |
29 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/camera/CameraOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import { pathForCamera, pathForCameraImage } from '@/app/path'; 5 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 6 | import { Camera } from '.'; 7 | import { descriptionForCameraPhotos, titleForCamera } from './meta'; 8 | import { useAppText } from '@/i18n/state/client'; 9 | 10 | export default function CameraOGTile({ 11 | camera, 12 | photos, 13 | count, 14 | dateRange, 15 | ...props 16 | }: { 17 | camera: Camera 18 | photos: Photo[] 19 | count?: number 20 | dateRange?: PhotoDateRangePostgres 21 | } & OGTilePropsCore) { 22 | const appText = useAppText(); 23 | return ( 24 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/HttpStatusPage.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import AppGrid from './AppGrid'; 3 | import { clsx } from 'clsx/lite'; 4 | import { PATH_ROOT } from '@/app/path'; 5 | import Link from 'next/link'; 6 | 7 | export default function HttpStatusPage({ 8 | status, 9 | children, 10 | }: { 11 | status: ReactNode 12 | children?: ReactNode 13 | }) { 14 | return ( 15 | 20 |

24 | {status} 25 |

26 |
27 | {children && 28 |
{children}
} 29 | 33 | Return Home 34 | 35 |
36 | 37 | } /> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/photo/PhotosLargeInfinite.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PATH_FULL_INFERRED } from '@/app/path'; 4 | import InfinitePhotoScroll from './InfinitePhotoScroll'; 5 | import PhotosLarge from './PhotosLarge'; 6 | import { SortBy } from './sort'; 7 | 8 | export default function PhotosLargeInfinite({ 9 | initialOffset, 10 | itemsPerPage, 11 | sortBy, 12 | excludeFromFeeds, 13 | }: { 14 | initialOffset: number 15 | itemsPerPage: number 16 | sortBy: SortBy 17 | sortWithPriority: boolean 18 | excludeFromFeeds?: boolean 19 | }) { 20 | return ( 21 | 29 | {({ key, photos, onLastPhotoVisible, revalidatePhoto }) => 30 | } 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth/server'; 2 | import SignInForm from '@/auth/SignInForm'; 3 | import { PATH_ADMIN, PATH_ROOT } from '@/app/path'; 4 | import { clsx } from 'clsx/lite'; 5 | import { redirect } from 'next/navigation'; 6 | import LinkWithStatus from '@/components/LinkWithStatus'; 7 | import { IoArrowBack } from 'react-icons/io5'; 8 | import { getAppText } from '@/i18n/state/server'; 9 | 10 | export default async function SignInPage() { 11 | const session = await auth(); 12 | 13 | if (session?.user) { 14 | redirect(PATH_ADMIN); 15 | } 16 | 17 | const appText = await getAppText(); 18 | 19 | return ( 20 |
24 | 25 | 32 | 33 | {appText.nav.home} 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { BiCopy } from 'react-icons/bi'; 2 | import LoaderButton from './primitives/LoaderButton'; 3 | import clsx from 'clsx/lite'; 4 | import { toastSuccess } from '@/toast'; 5 | import { ComponentProps } from 'react'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | 8 | export default function CopyButton({ 9 | label, 10 | text, 11 | subtle, 12 | iconSize = 15, 13 | className, 14 | ...props 15 | }: { 16 | label: string 17 | text?: string, 18 | subtle?: boolean 19 | iconSize?: number 20 | className?: string 21 | } & ComponentProps) { 22 | const appText = useAppText(); 23 | return ( 24 | } 27 | className={clsx( 28 | subtle && 'text-gray-300 dark:text-gray-700', 29 | className, 30 | )} 31 | onClick={text 32 | ? () => { 33 | navigator.clipboard.writeText(text); 34 | toastSuccess(appText.utility.copyPhrase(label)); 35 | } 36 | : undefined} 37 | styleAs="link" 38 | disabled={!text} 39 | /> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/focal/FocalLengthOGTile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import { 5 | pathForFocalLength, 6 | pathForFocalLengthImage, 7 | } from '@/app/path'; 8 | import OGTile, { OGTilePropsCore } from '@/components/og/OGTile'; 9 | import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.'; 10 | import { useAppText } from '@/i18n/state/client'; 11 | 12 | export default function FocalLengthOGTile({ 13 | focal, 14 | photos, 15 | count, 16 | dateRange, 17 | ...props 18 | }: { 19 | focal: number 20 | photos: Photo[] 21 | count?: number 22 | dateRange?: PhotoDateRangePostgres 23 | } & OGTilePropsCore) { 24 | const appText = useAppText(); 25 | return ( 26 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/MaskedScroll.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { HTMLAttributes, RefObject, useRef } from 'react'; 4 | import useMaskedScroll from './useMaskedScroll'; 5 | 6 | export default function MaskedScroll({ 7 | ref: refProp, 8 | enabled = true, 9 | direction, 10 | fadeSize, 11 | animationDuration, 12 | setMaxSize, 13 | hideScrollbar, 14 | updateMaskOnEvents, 15 | scrollToEndOnMount, 16 | style, 17 | children, 18 | ...props 19 | }: { 20 | ref?: RefObject 21 | enabled?: boolean 22 | } & HTMLAttributes 23 | & Omit[0], 'ref'>) { 24 | const refInternal = useRef(null); 25 | const ref = refProp ?? refInternal; 26 | 27 | const { styleMask } = useMaskedScroll({ 28 | ref, 29 | direction, 30 | fadeSize, 31 | animationDuration, 32 | setMaxSize, 33 | hideScrollbar, 34 | updateMaskOnEvents, 35 | scrollToEndOnMount, 36 | }); 37 | 38 | return
46 | {children} 47 |
; 48 | } 49 | -------------------------------------------------------------------------------- /src/tag/PhotoTag.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { pathForTag } from '@/app/path'; 4 | import { formatTag } from '.'; 5 | import EntityLink, { 6 | EntityLinkExternalProps, 7 | } from '@/components/entity/EntityLink'; 8 | import IconTag from '@/components/icons/IconTag'; 9 | import useCategoryCounts from '@/category/useCategoryCounts'; 10 | import { useAppState } from '@/app/AppState'; 11 | import AdminTagMenu from './AdminTagMenu'; 12 | 13 | export default function PhotoTag({ 14 | tag, 15 | showAdminMenu, 16 | ...props 17 | }: { 18 | tag: string 19 | showAdminMenu?: boolean 20 | } & EntityLinkExternalProps) { 21 | const { getTagCount } = useCategoryCounts(); 22 | const { isUserSignedIn } = useAppState(); 23 | const count = props.hoverCount ?? getTagCount(tag); 24 | return ( 25 | } 31 | hoverCount={count} 32 | action={showAdminMenu && isUserSignedIn && 33 | } 34 | /> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/admin/config/AdminAppConfigurationSidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import clsx from 'clsx/lite'; 4 | import { getAdminConfigSections } from '.'; 5 | import { parameterize } from '@/utility/string'; 6 | import useHash from '@/utility/useHash'; 7 | 8 | export default function AdminAppConfigurationSidebar({ 9 | simplifiedView, 10 | areInternalToolsEnabled, 11 | }: { 12 | simplifiedView?: boolean 13 | areInternalToolsEnabled: boolean 14 | }) { 15 | const { hash } = useHash(); 16 | 17 | return ( 18 |
22 | {getAdminConfigSections(areInternalToolsEnabled, simplifiedView) 23 | .map(({ title }) => ( 24 | 34 | {title} 35 | 36 | ))} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/platforms/next-image.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BASE_URL, 3 | IMAGE_QUALITY, 4 | VERCEL_BYPASS_KEY, 5 | VERCEL_BYPASS_SECRET, 6 | } from '@/app/config'; 7 | 8 | // Explicity defined next.config.js `imageSizes` 9 | type NextCustomSize = 200; 10 | 11 | type NextImageDeviceSize = 640 | 750 | 828 | 1080 | 1200 | 1920 | 2048 | 3840; 12 | 13 | export type NextImageSize = NextCustomSize | NextImageDeviceSize; 14 | 15 | export const MAX_IMAGE_SIZE: NextImageSize = 3840; 16 | 17 | export const getNextImageUrlForRequest = ({ 18 | imageUrl, 19 | size, 20 | quality = IMAGE_QUALITY, 21 | baseUrl = BASE_URL, 22 | addBypassSecret, 23 | }: { 24 | imageUrl: string 25 | size: NextImageSize 26 | quality?: number 27 | baseUrl?: string 28 | addBypassSecret?: boolean 29 | }) => { 30 | const url = new URL(`${baseUrl}/_next/image`); 31 | 32 | url.searchParams.append('url', imageUrl); 33 | url.searchParams.append('w', size.toString()); 34 | url.searchParams.append('q', quality.toString()); 35 | 36 | if (addBypassSecret && VERCEL_BYPASS_SECRET) { 37 | url.searchParams.append(VERCEL_BYPASS_KEY, VERCEL_BYPASS_SECRET); 38 | } 39 | 40 | return url.toString(); 41 | }; 42 | -------------------------------------------------------------------------------- /src/photo/color/client.ts: -------------------------------------------------------------------------------- 1 | export interface Oklch { 2 | l: number 3 | c: number 4 | h: number 5 | } 6 | 7 | export interface PhotoColorData { 8 | ai?: Oklch 9 | average: Oklch 10 | colors: Oklch[] 11 | } 12 | 13 | export const convertJsonStringToOklch = (jsonString = '') => { 14 | const matches = jsonString 15 | .match(/`*{ *l: *([0-9\.]+), *c: *([0-9\.]+), *h: *([0-9\.]+) *}`*/); 16 | if (matches && 17 | matches[1] && 18 | matches[2] && 19 | matches[3] 20 | ) { 21 | return { 22 | l: parseFloat(matches[1]), 23 | c: parseFloat(matches[2]), 24 | h: parseInt(matches[3]), 25 | } as Oklch; 26 | } 27 | }; 28 | 29 | export const convertOklchToCss = (oklch: Oklch) => 30 | `oklch(${oklch.l} ${oklch.c} ${oklch.h})`; 31 | 32 | export const logOklch = (oklch: Oklch) => 33 | `L:${oklch.l.toFixed(2)} C:${oklch.c.toFixed(2)} H:${oklch.h.toFixed(2)}`; 34 | 35 | export const generateColorDataFromString = (colorData?: string) => { 36 | if (colorData) { 37 | try { 38 | return JSON.parse(colorData) as PhotoColorData; 39 | } catch (error) { 40 | console.log('Error parsing color data', error); 41 | } 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/primitives/ProgressButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ComponentProps } from 'react'; 4 | import LoaderButton from './LoaderButton'; 5 | import { clsx } from 'clsx/lite'; 6 | 7 | const PROGRESS_PADDING = 0.1; 8 | 9 | export default function ProgressButton({ 10 | progress, 11 | isLoading, 12 | className, 13 | children, 14 | ...props 15 | }: { 16 | progress?: number 17 | } & ComponentProps) { 18 | const progressPadded = 19 | PROGRESS_PADDING + (progress ?? 0) * (1 - PROGRESS_PADDING); 20 | return ( 21 | 29 |
38 | {children} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/image-response/index.ts: -------------------------------------------------------------------------------- 1 | import { NextImageSize } from '@/platforms/next-image'; 2 | import { getDimensionsFromSize } from '@/utility/size'; 3 | 4 | export const MAX_PHOTOS_TO_SHOW_OG = 12; 5 | export const MAX_PHOTOS_TO_SHOW_PER_CATEGORY = 6; 6 | export const MAX_PHOTOS_TO_SHOW_TEMPLATE = 16; 7 | export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12; 8 | 9 | interface OGImageDimension { 10 | width: NextImageSize 11 | height: number 12 | aspectRatio: number 13 | } 14 | 15 | // 16:9 og image ratio 16 | const IMAGE_OG_RATIO = 16 / 9; 17 | const IMAGE_OG_WIDTH: NextImageSize = 1080; 18 | export const IMAGE_OG_DIMENSION = getDimensionsFromSize( 19 | IMAGE_OG_WIDTH, 20 | IMAGE_OG_RATIO, 21 | ) as OGImageDimension; 22 | 23 | // 16:9 og image ratio, small 24 | const IMAGE_OG_SMALL_WIDTH: NextImageSize = 828; 25 | export const IMAGE_OG_DIMENSION_SMALL = getDimensionsFromSize( 26 | IMAGE_OG_SMALL_WIDTH, 27 | IMAGE_OG_RATIO, 28 | ) as OGImageDimension; 29 | 30 | // 4:3 og grid ratio 31 | const GRID_OG_RATIO = 4 / 3; 32 | const GRID_OG_WIDTH: NextImageSize = 2048; 33 | export const GRID_OG_DIMENSION = getDimensionsFromSize( 34 | GRID_OG_WIDTH, 35 | GRID_OG_RATIO, 36 | ) as OGImageDimension; 37 | -------------------------------------------------------------------------------- /src/admin/AdminBadge.tsx: -------------------------------------------------------------------------------- 1 | import { photoLabelForCount } from '@/photo'; 2 | import { clsx } from 'clsx/lite'; 3 | import Badge from '@/components/Badge'; 4 | import { getAppText } from '@/i18n/state/server'; 5 | import { ReactNode } from 'react'; 6 | 7 | export default async function AdminBadge({ 8 | entity, 9 | count, 10 | hideBadge, 11 | className, 12 | }: { 13 | entity: ReactNode, 14 | count: number, 15 | hideBadge?: boolean, 16 | className?: string, 17 | }) { 18 | const appText = await getAppText(); 19 | 20 | const renderBadgeContent = () => 21 |
*>*:first-child]:items-center', 25 | className, 26 | )}> 27 | {entity} 28 |
29 | {count} 30 | 31 |   32 | {photoLabelForCount(count, appText)} 33 | 34 |
35 |
; 36 | 37 | return ( 38 | hideBadge 39 | ? renderBadgeContent() 40 | : {renderBadgeContent()} 41 | ); 42 | } -------------------------------------------------------------------------------- /src/photo/color/SyncColorButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import LoaderButton from '@/components/primitives/LoaderButton'; 4 | import { IoColorFilterOutline } from 'react-icons/io5'; 5 | import { 6 | recalculateColorDataForAllPhotosAction, 7 | storeColorDataForPhotoAction, 8 | } from '../actions'; 9 | import { useState } from 'react'; 10 | 11 | export default function SyncColorButton({ 12 | photoId, 13 | }: { 14 | photoId?: string 15 | }) { 16 | const [isUpdatingColorData, setIsUpdatingColorData] = useState(false); 17 | 18 | return ( 19 | } 21 | onClick={() => { 22 | setIsUpdatingColorData(true); 23 | (photoId 24 | ? storeColorDataForPhotoAction(photoId) 25 | : recalculateColorDataForAllPhotosAction()) 26 | .finally(() => setIsUpdatingColorData(false)); 27 | }} 28 | tooltip={photoId 29 | ? 'Update color data' 30 | : 'Update color data for all photos'} 31 | confirmText={!photoId 32 | ? 'Are you sure you want to update all photo color data?' 33 | : undefined} 34 | isLoading={isUpdatingColorData} 35 | /> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteCookie, getCookie, storeCookie } from '@/utility/cookie'; 2 | 3 | export const KEY_CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin'; 4 | export const KEY_CREDENTIALS_SIGN_IN_ERROR_URL = 5 | 'https://errors.authjs.dev#credentialssignin'; 6 | export const KEY_CREDENTIALS_CALLBACK_ROUTE_ERROR_URL = 7 | 'https://errors.authjs.dev#callbackrouteerror'; 8 | export const KEY_CREDENTIALS_SUCCESS = 'success'; 9 | export const KEY_CALLBACK_URL = 'callbackUrl'; 10 | 11 | const KEY_AUTH_EMAIL = 'authjs.email'; 12 | 13 | export const storeAuthEmailCookie = (email: string) => 14 | storeCookie(KEY_AUTH_EMAIL, email); 15 | 16 | export const getAuthEmailCookie = () => 17 | getCookie(KEY_AUTH_EMAIL); 18 | 19 | export const hasAuthEmailCookie = () => 20 | Boolean(getCookie(KEY_AUTH_EMAIL)); 21 | 22 | export const clearAuthEmailCookie = () => 23 | deleteCookie(KEY_AUTH_EMAIL); 24 | 25 | export const isCredentialsSignInError = (error?: any) => 26 | (error?.message || `${error}`).includes(KEY_CREDENTIALS_SIGN_IN_ERROR); 27 | 28 | export const generateAuthSecret = () => fetch( 29 | 'https://generate-secret.vercel.app/32', 30 | { cache: 'no-cache' }, 31 | ).then(res => res.text()); 32 | -------------------------------------------------------------------------------- /src/photo/form/usePhotoFormParent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { PhotoFormData, formHasExistingAiTextContent } from '.'; 3 | import useAiImageQueries from '../ai/useAiImageQueries'; 4 | 5 | export default function usePhotoFormParent({ 6 | photoForm, 7 | imageThumbnailBase64, 8 | }: { 9 | photoForm?: Partial 10 | imageThumbnailBase64?: string, 11 | }) { 12 | const [pending, setIsPending] = useState(false); 13 | const [updatedTitle, setUpdatedTitle] = useState(''); 14 | const [shouldConfirmAiTextGeneration, _setShouldConfirmAiTextGeneration] = 15 | useState(formHasExistingAiTextContent(photoForm)); 16 | 17 | const setShouldConfirmAiTextGeneration = useCallback( 18 | (updatedFormData: Partial) => { 19 | _setShouldConfirmAiTextGeneration( 20 | formHasExistingAiTextContent(updatedFormData), 21 | ); 22 | }, []); 23 | 24 | const aiContent = useAiImageQueries(imageThumbnailBase64); 25 | 26 | return { 27 | pending, 28 | setIsPending, 29 | updatedTitle, 30 | setUpdatedTitle, 31 | shouldConfirmAiTextGeneration, 32 | setShouldConfirmAiTextGeneration, 33 | aiContent, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/recipe/RecipeShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { absolutePathForRecipe } from '@/app/path'; 2 | import { PhotoSetAttributes } from '../category'; 3 | import ShareModal from '@/share/ShareModal'; 4 | import { 5 | formatRecipe, 6 | shareTextForRecipe, 7 | getRecipePropsFromPhotos, 8 | generateRecipeText, 9 | } from '.'; 10 | import RecipeOGTile from './RecipeOGTile'; 11 | import { useAppText } from '@/i18n/state/client'; 12 | 13 | export default function RecipeShareModal({ 14 | recipe, 15 | photos, 16 | count, 17 | dateRange, 18 | }: { 19 | recipe: string 20 | } & PhotoSetAttributes) { 21 | // Omit title from RecipeProps 22 | const { data, film } = getRecipePropsFromPhotos(photos) ?? {}; 23 | const recipeText = data && film 24 | ? generateRecipeText({ data, film }) 25 | : undefined; 26 | 27 | const appText = useAppText(); 28 | 29 | return ( 30 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/album/PhotoAlbum.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { pathForAlbum } from '@/app/path'; 4 | import EntityLink, { EntityLinkExternalProps } from 5 | '@/components/entity/EntityLink'; 6 | import IconAlbum from '@/components/icons/IconAlbum'; 7 | import { Album } from '.'; 8 | import useCategoryCounts from '@/category/useCategoryCounts'; 9 | import AdminAlbumMenu from './AdminAlbumMenu'; 10 | import { useAppState } from '@/app/AppState'; 11 | 12 | export default function PhotoAlbum({ 13 | album, 14 | showAdminMenu, 15 | ...props 16 | }: { 17 | album: Album 18 | showAdminMenu?: boolean 19 | } & EntityLinkExternalProps) { 20 | const { getAlbumCount } = useCategoryCounts(); 21 | const { isUserSignedIn } = useAppState(); 22 | const count = props.hoverCount ?? getAlbumCount(album); 23 | return ( 24 | } 30 | hoverCount={props.hoverCount ?? getAlbumCount(album)} 31 | action={showAdminMenu && isUserSignedIn && 32 | } 33 | /> 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/template-image/route.tsx: -------------------------------------------------------------------------------- 1 | import { getPhotosCached } from '@/photo/cache'; 2 | import { 3 | GRID_OG_DIMENSION, 4 | MAX_PHOTOS_TO_SHOW_TEMPLATE, 5 | } from '@/image-response'; 6 | import TemplateImageResponse from 7 | '@/app/TemplateImageResponse'; 8 | import { getIBMPlexMono } from '@/app/font'; 9 | import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; 10 | import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response'; 11 | 12 | export async function GET() { 13 | const [ 14 | photos, 15 | { fontFamily, fonts }, 16 | headers, 17 | ] = await Promise.all([ 18 | getPhotosCached({ 19 | sortWithPriority: true, 20 | limit: MAX_PHOTOS_TO_SHOW_TEMPLATE, 21 | }).catch(() => []), 22 | getIBMPlexMono(), 23 | getImageResponseCacheControlHeaders(), 24 | ]); 25 | 26 | const { width, height } = GRID_OG_DIMENSION; 27 | 28 | return safePhotoImageResponse( 29 | photos, 30 | isNextImageReady => ( 31 | 37 | ), 38 | { width, height, fonts, headers }, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/DownloadButton.tsx: -------------------------------------------------------------------------------- 1 | import { MdOutlineFileDownload } from 'react-icons/md'; 2 | import { clsx } from 'clsx/lite'; 3 | import { downloadFileNameForPhoto, Photo } from '@/photo'; 4 | import LoaderButton from './primitives/LoaderButton'; 5 | import { useState } from 'react'; 6 | import { downloadFileFromBrowser } from '@/utility/url'; 7 | import { useAppText } from '@/i18n/state/client'; 8 | 9 | export default function DownloadButton({ 10 | photo, 11 | className, 12 | }: { 13 | photo: Photo 14 | className?: string 15 | }) { 16 | const [isLoading, setIsLoading] = useState(false); 17 | 18 | const appText = useAppText(); 19 | 20 | return ( 21 | } 28 | spinnerColor='dim' 29 | styleAs='link' 30 | isLoading={isLoading} 31 | onClick={async () => { 32 | setIsLoading(true); 33 | downloadFileFromBrowser(photo.url, downloadFileNameForPhoto(photo)) 34 | .finally(() => setIsLoading(false)); 35 | }} 36 | hideFocusOutline 37 | /> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/photo/ai/useTitleCaptionAiImageQuery.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import useAiImageQuery from './useAiImageQuery'; 3 | import { parseTitleAndCaption } from '.'; 4 | 5 | export default function useTitleCaptionAiImageQuery( 6 | imageBase64?: string, 7 | ) { 8 | const [ 9 | request, 10 | text, 11 | isLoading, 12 | _reset, 13 | error, 14 | ] = useAiImageQuery(imageBase64, 'title-and-caption'); 15 | 16 | const [title, setTitle] = useState(''); 17 | const [caption, setCaption] = useState(''); 18 | useEffect(() => { 19 | const { title, caption } = parseTitleAndCaption(text); 20 | // eslint-disable-next-line react-hooks/set-state-in-effect 21 | setTitle(title); 22 | setCaption(caption); 23 | }, [text]); 24 | 25 | const resetTitle = useCallback(() => setTitle(''), []); 26 | const resetCaption = useCallback(() => setCaption(''), []); 27 | 28 | const isLoadingTitle = isLoading && !caption; 29 | const isLoadingCaption = isLoading; 30 | 31 | return [ 32 | request, 33 | title, 34 | caption, 35 | isLoadingTitle, 36 | isLoadingCaption, 37 | resetTitle, 38 | resetCaption, 39 | error, 40 | ] as const; 41 | } 42 | -------------------------------------------------------------------------------- /src/utility/useClientSearchParams.ts: -------------------------------------------------------------------------------- 1 | import { usePathname } from 'next/navigation'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | 4 | export default function useClientSearchParams( 5 | paramKey: string, 6 | enableScanning = true, 7 | ): string | undefined { 8 | const pathname = usePathname(); 9 | 10 | const [paramValue, setParamValue] = useState(); 11 | 12 | const captureParam = useCallback(() => { 13 | setParamValue(window.location.search.split(`${paramKey}=`)[1]); 14 | }, [paramKey]); 15 | 16 | useEffect(() => { 17 | window.addEventListener('popstate', captureParam); 18 | window.addEventListener('pushstate', captureParam); 19 | window.addEventListener('replacestate', captureParam); 20 | return () => { 21 | window.removeEventListener('popstate', captureParam); 22 | window.removeEventListener('pushstate', captureParam); 23 | window.removeEventListener('replacestate', captureParam); 24 | }; 25 | }, [captureParam]); 26 | 27 | useEffect(() => { 28 | // eslint-disable-next-line react-hooks/set-state-in-effect 29 | if (enableScanning) { captureParam(); } 30 | }, [pathname, captureParam, enableScanning]); 31 | 32 | return paramValue; 33 | }; 34 | -------------------------------------------------------------------------------- /src/utility/usePreventNavigation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function usePreventNavigation( 4 | enabled?: boolean, 5 | // eslint-disable-next-line max-len 6 | confirmation = 'Are you sure you want to leave this page? Any unsaved changes will be lost.', 7 | includeButtons?: boolean, 8 | ) { 9 | useEffect(() => { 10 | const callback = (e: MouseEvent) => { 11 | const target = e.target as HTMLElement | undefined; 12 | const parent = target?.parentElement as HTMLElement | undefined; 13 | const grandParent = parent?.parentElement as HTMLElement | undefined; 14 | const targets = [target, parent, grandParent]; 15 | if ( 16 | targets.some(target => target?.tagName === 'A') && ( 17 | !includeButtons || 18 | targets.some(target => target?.tagName === 'BUTTON') 19 | ) 20 | ) { 21 | if (enabled && !confirm(confirmation)) { 22 | e.stopPropagation(); 23 | e.preventDefault(); 24 | } 25 | } 26 | }; 27 | document.addEventListener('click', callback, true); 28 | return () => document.removeEventListener('click', callback, true); 29 | }, [enabled, confirmation, includeButtons]); 30 | } 31 | -------------------------------------------------------------------------------- /src/admin/AdminUploadsTable.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Dispatch, SetStateAction } from 'react'; 4 | import { UrlAddStatus } from './AdminUploadsClient'; 5 | import AdminUploadsTableRow from './AdminUploadsTableRow'; 6 | 7 | export default function AdminUploadsTable({ 8 | isAdding, 9 | urlAddStatuses, 10 | setUrlAddStatuses, 11 | isDeleting, 12 | setIsDeleting, 13 | }: { 14 | isAdding?: boolean 15 | urlAddStatuses: UrlAddStatus[] 16 | setUrlAddStatuses?: Dispatch> 17 | isDeleting?: boolean 18 | setIsDeleting?: Dispatch> 19 | }) { 20 | const isComplete = urlAddStatuses.every(({ status }) => status === 'added'); 21 | return ( 22 |
23 | {urlAddStatuses.map((status, index) => 24 | , 37 | )} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/recents/RecentsHeader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { descriptionForPhotoSet, Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import PhotoHeader from '@/photo/PhotoHeader'; 5 | import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config'; 6 | import { useAppText } from '@/i18n/state/client'; 7 | import PhotoRecents from './PhotoRecents'; 8 | 9 | export default function RecentsHeader({ 10 | photos, 11 | selectedPhoto, 12 | indexNumber, 13 | count, 14 | dateRange, 15 | }: { 16 | photos: Photo[] 17 | selectedPhoto?: Photo 18 | indexNumber?: number 19 | count?: number 20 | dateRange?: PhotoDateRangePostgres 21 | }) { 22 | const appText = useAppText(); 23 | 24 | return ( 25 | } 28 | entityDescription={descriptionForPhotoSet( 29 | photos, 30 | appText, 31 | undefined, 32 | undefined, 33 | count, 34 | )} 35 | photos={photos} 36 | selectedPhoto={selectedPhoto} 37 | indexNumber={indexNumber} 38 | count={count} 39 | dateRange={dateRange} 40 | hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED} 41 | includeShareButton 42 | /> 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/utility/key.ts: -------------------------------------------------------------------------------- 1 | const KEY_ALL = 'all'; 2 | const KEY_NONE = 'none'; 3 | 4 | export const parseCommaSeparatedKeyString = ({ 5 | string: _string, 6 | acceptedKeys, 7 | defaultKeys = [], 8 | }: { 9 | string: string | undefined, 10 | acceptedKeys: readonly T[], 11 | defaultKeys?: T[], 12 | }): T[] => { 13 | const string = (_string ?? '').trim().toLocaleLowerCase(); 14 | if (string === KEY_ALL) { 15 | return acceptedKeys.slice(); 16 | } else if (string === KEY_NONE) { 17 | return []; 18 | } else { 19 | return string 20 | ? string 21 | .split(',') 22 | .map(item => item.trim() as T) 23 | .filter(item => acceptedKeys.includes(item)) 24 | : defaultKeys; 25 | } 26 | }; 27 | export const getOrderedKeyListStatus = ({ 28 | selectedKeys, 29 | acceptedKeys, 30 | }: { 31 | selectedKeys: T[], 32 | acceptedKeys: readonly T[], 33 | }): { 34 | label: string, 35 | selected: boolean, 36 | }[] => 37 | selectedKeys 38 | .map((key, index) => ({ 39 | label: `${index + 1}.${key}`, 40 | selected: true, 41 | })) 42 | .concat(acceptedKeys 43 | .filter(key => !selectedKeys.includes(key)) 44 | .map(key => ({ 45 | label: `* ${key}`, 46 | selected: false, 47 | }))); 48 | -------------------------------------------------------------------------------- /src/year/YearImageResponse.tsx: -------------------------------------------------------------------------------- 1 | import { Photo } from '@/photo'; 2 | import ImageCaption from '@/image-response/components/ImageCaption'; 3 | import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid'; 4 | import ImageContainer from '@/image-response/components/ImageContainer'; 5 | import { NextImageSize } from '@/platforms/next-image'; 6 | import IconYear from '@/components/icons/IconYear'; 7 | 8 | export default function YearImageResponse({ 9 | year, 10 | photos, 11 | width, 12 | height, 13 | fontFamily, 14 | }: { 15 | year: string 16 | photos: Photo[] 17 | width: NextImageSize 18 | height: number 19 | fontFamily: string 20 | }) { 21 | return ( 22 | 23 | 30 | , 41 | title: year, 42 | }} /> 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/social/SocialButton.tsx: -------------------------------------------------------------------------------- 1 | import { FaFacebookF, FaLinkedin, FaThreads } from 'react-icons/fa6'; 2 | import { urlForSocial, SocialKey, tooltipForSocial } from '.'; 3 | import { PiXLogo } from 'react-icons/pi'; 4 | import Link from 'next/link'; 5 | import clsx from 'clsx/lite'; 6 | import Tooltip from '@/components/Tooltip'; 7 | import { useAppText } from '@/i18n/state/client'; 8 | 9 | const iconForSocialKey = (key: SocialKey) => { 10 | switch (key) { 11 | case 'x': return ; 12 | case 'threads': return ; 13 | case 'facebook': return ; 14 | case 'linkedin': return ; 15 | } 16 | }; 17 | 18 | export default function SocialButton({ 19 | socialKey, 20 | path, 21 | text, 22 | className, 23 | }: { 24 | socialKey: SocialKey 25 | path: string 26 | text: string 27 | className?: string 28 | }) { 29 | const appText = useAppText(); 30 | 31 | return ( 32 | 33 | 38 | {iconForSocialKey(socialKey)} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/css/viewerjs.css: -------------------------------------------------------------------------------- 1 | @import 'viewerjs/dist/viewer.css'; 2 | 3 | .viewer-canvas { 4 | background-color: black; 5 | } 6 | .viewer-reset::before { 7 | content: '1:1'; 8 | font-size: 13px; 9 | font-weight: 500; 10 | color: #fff; 11 | display: inline-block; 12 | position: relative; 13 | bottom: -9px; 14 | letter-spacing: -2px; 15 | background-image: none; 16 | } 17 | .viewer-close { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | top: 20px; 22 | right: 20px; 23 | } 24 | .viewer-close::before { 25 | transform: scale(1.1); 26 | } 27 | .viewer-button { 28 | width: 40px; 29 | height: 40px; 30 | } 31 | .viewer-button::before { 32 | bottom: auto; 33 | left: auto; 34 | } 35 | .viewer-button:focus { 36 | box-shadow: none; 37 | } 38 | .viewer-button:active { 39 | opacity: 0.5; 40 | } 41 | .viewer-toolbar > ul { 42 | display: flex; 43 | align-items: center; 44 | justify-content: center; 45 | gap: 0.5rem; 46 | padding: 0rem 0.25rem 2rem 0.25rem; 47 | } 48 | .viewer-toolbar > ul > li { 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | width: 36px; 53 | height: 36px; 54 | } 55 | .viewer-toolbar > ul > li:focus { 56 | box-shadow: none; 57 | } 58 | .viewer-reset::before { 59 | left:-1px; 60 | } 61 | -------------------------------------------------------------------------------- /src/recents/RecentsImageResponse.tsx: -------------------------------------------------------------------------------- 1 | import { Photo } from '@/photo'; 2 | import ImageCaption from '../image-response/components/ImageCaption'; 3 | import ImagePhotoGrid from '../image-response/components/ImagePhotoGrid'; 4 | import ImageContainer from '../image-response/components/ImageContainer'; 5 | import { NextImageSize } from '@/platforms/next-image'; 6 | import IconRecents from '@/components/icons/IconRecents'; 7 | 8 | export default function RecentsImageResponse({ 9 | title, 10 | photos, 11 | width, 12 | height, 13 | fontFamily, 14 | }: { 15 | title: string 16 | photos: Photo[] 17 | width: NextImageSize 18 | height: number 19 | fontFamily: string 20 | }) { 21 | return ( 22 | 23 | 30 | , 41 | title, 42 | }} /> 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/recipe/useRecipeOverlay.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useMemo, useState } from 'react'; 2 | import useClickInsideOutside from '@/utility/useClickInsideOutside'; 3 | import useScrollIntoView from '@/utility/useScrollIntoView'; 4 | 5 | export default function useRecipeOverlay({ 6 | ref, 7 | refTriggers = [], 8 | }: { 9 | ref?: RefObject, 10 | refTriggers?: RefObject[], 11 | }) { 12 | const [isShowingRecipeOverlay, setIsShowingRecipeOverlay] = useState(false); 13 | 14 | const showRecipeOverlay = 15 | useCallback(() => setIsShowingRecipeOverlay(true), []); 16 | const hideRecipeOverlay = 17 | useCallback(() => setIsShowingRecipeOverlay(false), []); 18 | const toggleRecipeOverlay = useCallback(() => 19 | setIsShowingRecipeOverlay(current => !current), 20 | []); 21 | 22 | const htmlElements = useMemo(() => 23 | [ref, ...refTriggers], [ref, refTriggers]); 24 | 25 | useClickInsideOutside({ 26 | htmlElements, 27 | onClickOutside: hideRecipeOverlay, 28 | }); 29 | 30 | useScrollIntoView({ 31 | ref, 32 | shouldScrollIntoView: isShowingRecipeOverlay, 33 | }); 34 | 35 | return { 36 | isShowingRecipeOverlay, 37 | showRecipeOverlay, 38 | hideRecipeOverlay, 39 | toggleRecipeOverlay, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/admin/insights/InsightsIndicatorDot.tsx: -------------------------------------------------------------------------------- 1 | import { useAppState } from '@/app/AppState'; 2 | import clsx from 'clsx/lite'; 3 | import { FaCircle } from 'react-icons/fa6'; 4 | 5 | export default function InsightsIndicatorDot({ 6 | className, 7 | size = 'medium', 8 | colorOverride, 9 | top, 10 | right, 11 | bottom, 12 | left, 13 | }: { 14 | className?: string 15 | size?: 'small' | 'medium' | 'large' 16 | colorOverride?: 'blue' | 'yellow' 17 | top?: number 18 | right?: number 19 | bottom?: number 20 | left?: number 21 | }) { 22 | const { insightsIndicatorStatus } = useAppState(); 23 | 24 | const getSize = () => { 25 | switch (size) { 26 | case 'small': return 6; 27 | case 'medium': return 7; 28 | case 'large': return 8; 29 | } 30 | }; 31 | 32 | return ( 33 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/home-image/route.tsx: -------------------------------------------------------------------------------- 1 | import { getPhotosCached } from '@/photo/cache'; 2 | import { 3 | IMAGE_OG_DIMENSION_SMALL, 4 | MAX_PHOTOS_TO_SHOW_OG, 5 | } from '@/image-response'; 6 | import HomeImageResponse from '@/app/HomeImageResponse'; 7 | import { getIBMPlexMono } from '@/app/font'; 8 | import { getImageResponseCacheControlHeaders } from '@/image-response/cache'; 9 | import { APP_OG_IMAGE_QUERY_OPTIONS } from '@/feed'; 10 | import { safePhotoImageResponse } from '@/platforms/safe-photo-image-response'; 11 | 12 | export const dynamic = 'force-static'; 13 | 14 | export async function GET() { 15 | const [ 16 | photos, 17 | headers, 18 | { fontFamily, fonts }, 19 | ] = await Promise.all([ 20 | getPhotosCached({ 21 | ...APP_OG_IMAGE_QUERY_OPTIONS, 22 | limit: MAX_PHOTOS_TO_SHOW_OG, 23 | }) 24 | .catch(() => []), 25 | getImageResponseCacheControlHeaders(), 26 | getIBMPlexMono(), 27 | ]); 28 | 29 | const { width, height } = IMAGE_OG_DIMENSION_SMALL; 30 | 31 | return safePhotoImageResponse( 32 | photos, 33 | isNextImageReady => ( 34 | 40 | ), { width, height, headers, fonts }, 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/film/FilmImageResponse.tsx: -------------------------------------------------------------------------------- 1 | import { Photo } from '../photo'; 2 | import ImageCaption from '@/image-response/components/ImageCaption'; 3 | import ImagePhotoGrid from '@/image-response/components/ImagePhotoGrid'; 4 | import ImageContainer from '@/image-response/components/ImageContainer'; 5 | import PhotoFilmIcon from 6 | '@/film/PhotoFilmIcon'; 7 | import { NextImageSize } from '@/platforms/next-image'; 8 | import { labelForFilm } from '@/film'; 9 | 10 | export default function FilmImageResponse({ 11 | film, 12 | photos, 13 | width, 14 | height, 15 | fontFamily, 16 | }: { 17 | film: string, 18 | photos: Photo[] 19 | width: NextImageSize 20 | height: number 21 | fontFamily: string 22 | }) { 23 | return ( 24 | 25 | 32 | , 41 | title: labelForFilm(film).medium.toLocaleUpperCase(), 42 | }} /> 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/year/YearHeader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { descriptionForPhotoSet, Photo, PhotoDateRangePostgres } from '@/photo'; 4 | import PhotoHeader from '@/photo/PhotoHeader'; 5 | import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config'; 6 | import PhotoYear from './PhotoYear'; 7 | import { useAppText } from '@/i18n/state/client'; 8 | 9 | export default function YearHeader({ 10 | year, 11 | photos, 12 | selectedPhoto, 13 | indexNumber, 14 | count, 15 | dateRange, 16 | }: { 17 | year: string 18 | photos: Photo[] 19 | selectedPhoto?: Photo 20 | indexNumber?: number 21 | count?: number 22 | dateRange?: PhotoDateRangePostgres 23 | }) { 24 | const appText = useAppText(); 25 | 26 | return ( 27 | } 34 | entityDescription={descriptionForPhotoSet( 35 | photos, 36 | appText, 37 | undefined, 38 | undefined, 39 | count, 40 | )} 41 | photos={photos} 42 | selectedPhoto={selectedPhoto} 43 | indexNumber={indexNumber} 44 | count={count} 45 | dateRange={dateRange} 46 | hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED} 47 | includeShareButton 48 | /> 49 | ); 50 | } -------------------------------------------------------------------------------- /src/photo/PhotosLarge.tsx: -------------------------------------------------------------------------------- 1 | import AnimateItems from '@/components/AnimateItems'; 2 | import { Photo } from '.'; 3 | import PhotoLarge from './PhotoLarge'; 4 | import { RevalidatePhoto } from './InfinitePhotoScroll'; 5 | 6 | export default function PhotosLarge({ 7 | photos, 8 | animate = true, 9 | prefetchFirstPhotoLinks, 10 | onLastPhotoVisible, 11 | revalidatePhoto, 12 | }: { 13 | photos: Photo[] 14 | animate?: boolean 15 | prefetchFirstPhotoLinks?: boolean 16 | onLastPhotoVisible?: () => void 17 | revalidatePhoto?: RevalidatePhoto 18 | }) { 19 | return ( 20 | 28 | )} 39 | itemKeys={photos.map(photo => photo.id)} 40 | /> 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/photo/PhotoGridInfinite.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { INFINITE_SCROLL_GRID_MULTIPLE } from '.'; 4 | import InfinitePhotoScroll from './InfinitePhotoScroll'; 5 | import PhotoGrid from './PhotoGrid'; 6 | import { ComponentProps } from 'react'; 7 | import { SortBy } from './sort'; 8 | 9 | export default function PhotoGridInfinite({ 10 | cacheKey, 11 | initialOffset, 12 | sortBy, 13 | sortWithPriority, 14 | excludeFromFeeds, 15 | canStart, 16 | animateOnFirstLoadOnly, 17 | ...categories 18 | }: { 19 | cacheKey: string 20 | initialOffset: number 21 | sortBy?: SortBy 22 | sortWithPriority?: boolean 23 | excludeFromFeeds?: boolean 24 | } & Omit, 'photos'>) { 25 | return ( 26 | 35 | {({ key, photos, onLastPhotoVisible }) => 36 | } 43 | 44 | ); 45 | } 46 | --------------------------------------------------------------------------------