diff --git a/.vscode/settings.json b/.vscode/settings.json index 6eb8f0ac..91af0e49 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,24 +1,31 @@ { "cSpell.words": [ "ABCDEFGHIJKLMNOP", + "Acros", "ARROWLEFT", "ARROWRIGHT", + "Astia", "camelcase", + "Eterna", "exif", + "exiftool", "ghijklmnopqrstuv", "hgetall", "hset", "Lightbox", "nanoids", "nextjs", + "Provia", "qaub", "QRSTUVWXYZ", + "Reala", "skippable", "sonner", "thephotoblog", "trpc", "unnest", "UsKSGcbt", + "Velvia", "WRHGZC", "wxyz", "zadd", diff --git a/README.md b/README.md index 12806363..6a522602 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Features - Built-in auth - Light/dark mode - Automatic OG image generation +- Support for Fujifilm film simulations OG Image Preview diff --git a/src/app/(auth-state)/admin/photos/page.tsx b/src/app/(auth-state)/admin/photos/page.tsx index 02a54941..43409cb1 100644 --- a/src/app/(auth-state)/admin/photos/page.tsx +++ b/src/app/(auth-state)/admin/photos/page.tsx @@ -12,7 +12,7 @@ import { pathForAdminPhotoEdit, } from '@/site/paths'; import { titleForPhoto } from '@/photo'; -import MorePhotos from '@/components/MorePhotos'; +import MorePhotos from '@/photo/MorePhotos'; import { getBlobPhotoUrlsCached, getPhotosCached, diff --git a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx index a4ea30e5..a908701f 100644 --- a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx @@ -1,9 +1,14 @@ import PhotoForm from '@/photo/PhotoForm'; -import { ExifParserFactory } from 'ts-exif-parser'; +import { ExifData, ExifParserFactory } from 'ts-exif-parser'; import { convertExifToFormData } from '@/photo/form'; import AdminChildPage from '@/components/AdminChildPage'; import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob'; import { PATH_ADMIN_UPLOADS } from '@/site/paths'; +import { + FujifilmSimulation, + getFujifilmSimulationFromMakerNote, + isExifForFujifilm, +} from '@/vendors/fujifilm'; interface Params { params: { uploadPath: string } @@ -19,12 +24,27 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { .then(res => res.arrayBuffer()) : undefined; - let data; + let exifDataForm: ExifData | undefined; + let filmSimulation: FujifilmSimulation | undefined; if (fileBytes) { - data = ExifParserFactory - .create(Buffer.from(fileBytes)) - .parse(); + const parser = ExifParserFactory.create(Buffer.from(fileBytes)); + + // Data for form + parser.enableBinaryFields(false); + exifDataForm = parser.parse(); + + // Capture film simulation for Fujifilm cameras + if (isExifForFujifilm(exifDataForm)) { + // Parse exif data again with binary fields + // in order to access MakerNote tag + parser.enableBinaryFields(true); + const exifDataBinary = parser.parse(); + const makerNote = exifDataBinary.tags?.MakerNote; + if (Buffer.isBuffer(makerNote)) { + filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); + } + } } return ( @@ -33,12 +53,12 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { backLabel="Uploads" breadcrumb={getIdFromBlobUrl(url)} > - {data + {exifDataForm ? : null} diff --git a/src/app/(auth-state)/layout.tsx b/src/app/(auth-state)/layout.tsx index f98f925d..1093e968 100644 --- a/src/app/(auth-state)/layout.tsx +++ b/src/app/(auth-state)/layout.tsx @@ -1,4 +1,4 @@ -import FooterAuth from '@/components/FooterAuth'; +import FooterAuth from '@/site/FooterAuth'; import PageContentContainer from '@/components/PageContentContainer'; import { SessionProvider } from 'next-auth/react'; diff --git a/src/app/(static)/film/page.tsx b/src/app/(static)/film/page.tsx new file mode 100644 index 00000000..c9ca28cc --- /dev/null +++ b/src/app/(static)/film/page.tsx @@ -0,0 +1,14 @@ +import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/vendors/fujifilm'; +import PhotoFujifilmSimulation from + '@/vendors/fujifilm/PhotoFujifilmSimulation'; + +export default function FilmPage() { + return ( +
+ {FILM_SIMULATION_FORM_INPUT_OPTIONS.map(({ value }) => +
+ +
)} +
+ ); +} diff --git a/src/app/(static)/layout.tsx b/src/app/(static)/layout.tsx index abfc0e8b..f4fa896f 100644 --- a/src/app/(static)/layout.tsx +++ b/src/app/(static)/layout.tsx @@ -1,4 +1,4 @@ -import FooterStatic from '@/components/FooterStatic'; +import FooterStatic from '@/site/FooterStatic'; import PageContentContainer from '@/components/PageContentContainer'; export default function RootLayout({ diff --git a/src/app/(static)/og/page.tsx b/src/app/(static)/og/page.tsx index 13ddc5bd..11ad9f75 100644 --- a/src/app/(static)/og/page.tsx +++ b/src/app/(static)/og/page.tsx @@ -1,5 +1,5 @@ import { getPhotosCached, getPhotosCountCached } from '@/cache'; -import MorePhotos from '@/components/MorePhotos'; +import MorePhotos from '@/photo/MorePhotos'; import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos'; import { PaginationParams, diff --git a/src/app/(static)/page.tsx b/src/app/(static)/page.tsx index 6d3959ee..129c7799 100644 --- a/src/app/(static)/page.tsx +++ b/src/app/(static)/page.tsx @@ -1,6 +1,6 @@ import { getPhotosCached, getPhotosCountCached } from '@/cache'; import AnimateItems from '@/components/AnimateItems'; -import MorePhotos from '@/components/MorePhotos'; +import MorePhotos from '@/photo/MorePhotos'; import SiteGrid from '@/components/SiteGrid'; import { generateOgImageMetaForPhotos } from '@/photo'; import PhotoLarge from '@/photo/PhotoLarge'; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 197c834a..fdbbcd7c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import { Metadata } from 'next'; import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config'; import StateProvider from '@/state/AppStateProvider'; import ThemeProviderClient from '@/site/ThemeProviderClient'; -import Nav from '@/components/Nav'; +import Nav from '@/site/Nav'; import ToasterWithThemes from '@/components/ToasterWithThemes'; import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler'; diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index affe5150..c518e37d 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -11,6 +11,8 @@ export default function FieldSetWithStatus({ note, value, onChange, + selectOptions, + selectOptionsDefaultLabel, placeholder, loading, required, @@ -23,6 +25,8 @@ export default function FieldSetWithStatus({ note?: string value: string onChange?: (value: string) => void + selectOptions?: { value: string, label: string }[] + selectOptionsDefaultLabel?: string placeholder?: string loading?: boolean required?: boolean @@ -52,21 +56,43 @@ export default function FieldSetWithStatus({ } - onChange?.(type === 'checkbox' - ? e.target.value === 'true' ? 'false' : 'true' - : e.target.value)} - type={type} - autoComplete="off" - readOnly={readOnly || pending} - className={cc(type === 'text' && 'w-full')} - /> + {selectOptions + ? + : onChange?.(type === 'checkbox' + ? e.target.value === 'true' ? 'false' : 'true' + : e.target.value)} + type={type} + autoComplete="off" + readOnly={readOnly || pending} + className={cc(type === 'text' && 'w-full')} + />} ); }; diff --git a/src/components/IconPathButton.tsx b/src/components/IconPathButton.tsx index 7c6d64f0..7f5b923e 100644 --- a/src/components/IconPathButton.tsx +++ b/src/components/IconPathButton.tsx @@ -60,8 +60,8 @@ export default function IconPathButton({ className={cc( 'translate-y-[-0.5px]', 'active:translate-y-[1px]', - 'text-gray-500 active:text-gray-600', - 'dark:text-gray-400 dark:active:text-gray-300', + 'text-medium', + 'active:text-gray-600 dark:active:text-gray-300', )} spinnerColor={spinnerColor ?? 'text'} /> diff --git a/src/components/InfoBlock.tsx b/src/components/InfoBlock.tsx index 8fc31593..982c0e78 100644 --- a/src/components/InfoBlock.tsx +++ b/src/components/InfoBlock.tsx @@ -30,7 +30,7 @@ export default function InfoBlock({
{children}
diff --git a/src/components/OGTile.tsx b/src/components/OGTile.tsx index 868a9b8e..da11f0a7 100644 --- a/src/components/OGTile.tsx +++ b/src/components/OGTile.tsx @@ -121,7 +121,7 @@ export default function OGTile({
{title}
-
+
{description}
diff --git a/src/components/RepoLink.tsx b/src/components/RepoLink.tsx index 6aa86c31..329c6908 100644 --- a/src/components/RepoLink.tsx +++ b/src/components/RepoLink.tsx @@ -17,7 +17,7 @@ export default function RepoLink() { 'hover:underline', )} > - + exif-photo-blog diff --git a/src/components/MorePhotos.tsx b/src/photo/MorePhotos.tsx similarity index 96% rename from src/components/MorePhotos.tsx rename to src/photo/MorePhotos.tsx index dadcd616..c7902d5d 100644 --- a/src/components/MorePhotos.tsx +++ b/src/photo/MorePhotos.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useTransition } from 'react'; -import Spinner from './Spinner'; +import Spinner from '../components/Spinner'; export default function MorePhotos({ path, diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index b62d1c41..9f680de0 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -96,12 +96,18 @@ export default function PhotoForm({ label, note, required, + options, + optionsDefaultLabel, readOnly, hideIfEmpty, + hideBasedOnCamera, loadingMessage, checkbox, }]) => - (!hideIfEmpty || formData[key]) && + ( + (!hideIfEmpty || formData[key]) && + !hideBasedOnCamera?.(formData.make) + ) && setFormData({ ...formData, [key]: value })} + selectOptions={options} + selectOptionsDefaultLabel={optionsDefaultLabel} required={required} readOnly={readOnly} placeholder={loadingMessage && !formData[key] diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index bba6a5c6..4607ed48 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -3,7 +3,7 @@ import PhotoSmall from './PhotoSmall'; import { cc } from '@/utility/css'; import AnimateItems from '@/components/AnimateItems'; import { Camera } from '@/camera'; -import MorePhotos from '@/components/MorePhotos'; +import MorePhotos from '@/photo/MorePhotos'; export default function PhotoGrid({ photos, diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index ce16d094..6babea86 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -8,6 +8,8 @@ import PhotoTags from '@/tag/PhotoTags'; import ShareButton from '@/components/ShareButton'; import PhotoCamera from '../camera/PhotoCamera'; import { Camera, cameraFromPhoto } from '@/camera'; +import PhotoFujifilmSimulation from + '@/vendors/fujifilm/PhotoFujifilmSimulation'; export default function PhotoLarge({ photo, @@ -73,18 +75,24 @@ export default function PhotoLarge({ } {showCamera && photoHasCameraData(photo) && - } +
+ + {photo.filmSimulation && + <> +
+ + } +
} )} {renderMiniGrid(<> {photoHasExifData(photo) && -
    +
    • {photo.focalLengthFormatted} {photo.focalLengthIn35MmFormatFormatted && @@ -112,8 +120,7 @@ export default function PhotoLarge({ )}>
      {photo.takenAtNaiveFormatted}
      diff --git a/src/photo/PhotosEmptyState.tsx b/src/photo/PhotosEmptyState.tsx index ac9fadd0..c17dd053 100644 --- a/src/photo/PhotosEmptyState.tsx +++ b/src/photo/PhotosEmptyState.tsx @@ -12,7 +12,7 @@ export default function PhotosEmptyState() { contentMain={
      ; @@ -18,21 +22,34 @@ type FormMeta = { readOnly?: boolean hideIfEmpty?: boolean hideTemporarily?: boolean + hideBasedOnCamera?: (make?: string, mode?: string) => boolean loadingMessage?: string checkbox?: boolean + options?: { value: string, label: string }[] + optionsDefaultLabel?: string }; const FORM_METADATA: Record = { title: { label: 'title' }, tags: { label: 'tags', note: 'comma-separated values' }, id: { label: 'id', readOnly: true, hideIfEmpty: true }, - // eslint-disable-next-line max-len - blurData: { label: 'blur data', readOnly: true, required: true, loadingMessage: 'Generating blur data ...' }, + blurData: { + label: 'blur data', + readOnly: true, + required: true, + loadingMessage: 'Generating blur data ...', + }, url: { label: 'url', readOnly: true }, extension: { label: 'extension', readOnly: true }, aspectRatio: { label: 'aspect ratio', readOnly: true }, make: { label: 'camera make' }, model: { label: 'camera model' }, + filmSimulation: { + label: 'fujifilm simulation', + options: FILM_SIMULATION_FORM_INPUT_OPTIONS, + optionsDefaultLabel: 'Unknown', + hideBasedOnCamera: make => make !== 'FUJIFILM', + }, focalLength: { label: 'focal length' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, fNumber: { label: 'aperture' }, @@ -42,7 +59,6 @@ const FORM_METADATA: Record = { locationName: { label: 'location name', hideTemporarily: true }, latitude: { label: 'latitude' }, longitude: { label: 'longitude' }, - filmSimulation: { label: 'film simulation', hideTemporarily: true }, priorityOrder: { label: 'priority order' }, takenAt: { label: 'taken at' }, takenAtNaive: { label: 'taken at (naive)' }, @@ -77,7 +93,8 @@ export const convertPhotoToFormData = ( }; export const convertExifToFormData = ( - data: ExifData + data: ExifData, + fujifilmSimulation?: FujifilmSimulation, ): Record => ({ aspectRatio: ( (data.imageSize?.width ?? 3.0) / @@ -93,7 +110,7 @@ export const convertExifToFormData = ( exposureCompensation: data.tags?.ExposureCompensation?.toString(), latitude: data.tags?.GPSLatitude?.toString(), longitude: data.tags?.GPSLongitude?.toString(), - filmSimulation: undefined, + filmSimulation: fujifilmSimulation, takenAt: data.tags?.DateTimeOriginal ? convertTimestampWithOffsetToPostgresString( data.tags?.DateTimeOriginal, @@ -124,9 +141,9 @@ export const convertFormDataToPhoto = ( }); return { - ...photoForm, + ...(photoForm as PhotoFormData & { filmSimulation?: FujifilmSimulation }), ...(generateId && !photoForm.id) && { id: generateNanoid() }, - // convert form strings to arrays + // Convert form strings to arrays tags: convertStringToArray(photoForm.tags), // Convert form strings to numbers aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6), diff --git a/src/photo/image-response/TemplateImageResponse.tsx b/src/photo/image-response/TemplateImageResponse.tsx index a82ea469..c4f930e5 100644 --- a/src/photo/image-response/TemplateImageResponse.tsx +++ b/src/photo/image-response/TemplateImageResponse.tsx @@ -1,6 +1,6 @@ import { Photo } from '..'; -import IconFullFrame from '@/icons/IconFullFrame'; -import IconGrid from '@/icons/IconGrid'; +import IconFullFrame from '@/site/IconFullFrame'; +import IconGrid from '@/site/IconGrid'; import ImagePhotoGrid from './components/ImagePhotoGrid'; export default function TemplateImageResponse({ diff --git a/src/photo/index.ts b/src/photo/index.ts index 3d44a2c3..6f2fe489 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -7,6 +7,7 @@ import { formatExposureTime, formatFocalLength, } from '@/utility/exif'; +import { FujifilmSimulation } from '@/vendors/fujifilm'; import camelcaseKeys from 'camelcase-keys'; import type { Metadata } from 'next'; @@ -30,7 +31,7 @@ export interface PhotoExif { exposureCompensation?: number latitude?: number longitude?: number - filmSimulation?: string + filmSimulation?: FujifilmSimulation takenAt: string takenAtNaive: string } diff --git a/src/components/FooterAuth.tsx b/src/site/FooterAuth.tsx similarity index 96% rename from src/components/FooterAuth.tsx rename to src/site/FooterAuth.tsx index 17f7e585..cab90f4e 100644 --- a/src/components/FooterAuth.tsx +++ b/src/site/FooterAuth.tsx @@ -4,7 +4,7 @@ import { cc } from '@/utility/css'; import Link from 'next/link'; import { useSession, signOut } from 'next-auth/react'; import ThemeSwitcher from '@/site/ThemeSwitcher'; -import SiteGrid from './SiteGrid'; +import SiteGrid from '../components/SiteGrid'; import { usePathname } from 'next/navigation'; import { isPathSignIn } from '@/site/paths'; diff --git a/src/components/FooterStatic.tsx b/src/site/FooterStatic.tsx similarity index 92% rename from src/components/FooterStatic.tsx rename to src/site/FooterStatic.tsx index e28ee553..60403887 100644 --- a/src/components/FooterStatic.tsx +++ b/src/site/FooterStatic.tsx @@ -1,12 +1,12 @@ 'use client'; import { cc } from '@/utility/css'; -import SiteGrid from './SiteGrid'; +import SiteGrid from '../components/SiteGrid'; import ThemeSwitcher from '@/site/ThemeSwitcher'; import { signOut } from 'next-auth/react'; import Link from 'next/link'; import { SHOW_REPO_LINK } from '@/site/config'; -import RepoLink from './RepoLink'; +import RepoLink from '../components/RepoLink'; export default function FooterStatic({ showSignOut, diff --git a/src/icons/IconFullFrame.tsx b/src/site/IconFullFrame.tsx similarity index 100% rename from src/icons/IconFullFrame.tsx rename to src/site/IconFullFrame.tsx diff --git a/src/icons/IconGrid.tsx b/src/site/IconGrid.tsx similarity index 100% rename from src/icons/IconGrid.tsx rename to src/site/IconGrid.tsx diff --git a/src/components/Nav.tsx b/src/site/Nav.tsx similarity index 92% rename from src/components/Nav.tsx rename to src/site/Nav.tsx index ec357834..fc44c595 100644 --- a/src/components/Nav.tsx +++ b/src/site/Nav.tsx @@ -3,9 +3,9 @@ import { cc } from '@/utility/css'; import { usePathname } from 'next/navigation'; import Link from 'next/link'; -import SiteGrid from './SiteGrid'; +import SiteGrid from '../components/SiteGrid'; import { SITE_DOMAIN_OR_TITLE } from '@/site/config'; -import ViewSwitcher, { SwitcherSelection } from '@/photo/ViewSwitcher'; +import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher'; import { PATH_ADMIN, PATH_ROOT, @@ -14,7 +14,7 @@ import { isPathProtected, isPathSignIn, } from '@/site/paths'; -import AnimateItems from './AnimateItems'; +import AnimateItems from '../components/AnimateItems'; export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) { const isLoggedIn = false; diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index b951372e..ae2e9759 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -100,9 +100,9 @@ export default function SiteChecklistClient({ > `{variable}` @@ -261,7 +261,7 @@ export default function SiteChecklistClient({ Check
      } -
      +
      Changes to environment variables require a redeploy or reboot of local dev server
      diff --git a/src/photo/ViewSwitcher.tsx b/src/site/ViewSwitcher.tsx similarity index 91% rename from src/photo/ViewSwitcher.tsx rename to src/site/ViewSwitcher.tsx index 2f82744f..02a7969b 100644 --- a/src/photo/ViewSwitcher.tsx +++ b/src/site/ViewSwitcher.tsx @@ -1,7 +1,7 @@ import Switcher from '@/components/Switcher'; import SwitcherItem from '@/components/SwitcherItem'; -import IconFullFrame from '@/icons/IconFullFrame'; -import IconGrid from '@/icons/IconGrid'; +import IconFullFrame from '@/site/IconFullFrame'; +import IconGrid from '@/site/IconGrid'; import { PATH_GRID } from '@/site/paths'; import { BiLockAlt } from 'react-icons/bi'; diff --git a/src/site/globals.css b/src/site/globals.css index 5cfbf843..71026ab4 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -14,29 +14,39 @@ label { @apply font-sans font-medium block uppercase text-xs - text-gray-500 dark:text-gray-400 + text-medium tracking-wider } button, .button, - input[type=text], input[type=email], input[type=password] { + input[type=text], input[type=email], input[type=password], select { @apply - px-2 py-1.5 + px-2.5 py-2 border rounded-md bg-white dark:bg-black border-gray-200 dark:border-gray-700 - font-mono text-base leading-none + font-mono text-base leading-tight min-h-[2.25rem] } - input[type=text], input[type=email], input[type=password] { + input[type=text], input[type=email], input[type=password], select { @apply text-[1rem] /* Prevent iOS auto-zoom behavior */ min-w-[20rem] read-only:cursor-default + } + input[type=text], input[type=email], input[type=password] { + @apply read-only:bg-gray-100 dark:read-only:bg-gray-900 dark:read-only:text-gray-400 } + /* Required for readonly behavior on