;
@@ -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
*/
+ .disabled-select {
+ @apply
+ bg-gray-100
+ dark:bg-gray-900 dark:text-gray-400
+ pointer-events-none
+ }
input[type=file] {
@apply
- block font-mono w-full text-gray-500 dark:text-gray-400
+ block font-mono w-full text-medium
file:bg-white dark:file:bg-gray-950
file:mr-2 file:my-2 file:px-4 file:py-1.5 file:rounded-md
file:border-solid file:border
@@ -85,7 +95,7 @@
}
button.primary.disabled, .button.primary.disabled {
@apply
- text-extra-dim
+ text-medium
}
/* Toasts */
.toaster [data-sonner-toast] {
@@ -102,14 +112,14 @@
@apply
text-gray-100 dark:text-gray-900
}
+ .text-medium {
+ @apply
+ text-gray-500 dark:text-gray-400
+ }
.text-dim {
@apply
text-gray-400 dark:text-gray-500
}
- .text-extra-dim {
- @apply
- text-gray-500 dark:text-gray-400
- }
.text-icon {
@apply
text-gray-800 dark:text-gray-200
diff --git a/src/vendors/fujifilm/PhotoFujifilmSimulation.tsx b/src/vendors/fujifilm/PhotoFujifilmSimulation.tsx
new file mode 100644
index 00000000..3bab682b
--- /dev/null
+++ b/src/vendors/fujifilm/PhotoFujifilmSimulation.tsx
@@ -0,0 +1,30 @@
+/* eslint-disable max-len */
+import { cc } from '@/utility/css';
+import {
+ FujifilmSimulation,
+ getLabelForFilmSimulation,
+} from '@/vendors/fujifilm';
+import PhotoFujifilmSimulationIcon from './PhotoFujifilmSimulationIcon';
+
+export default function PhotoFujifilmSimulation({
+ simulation,
+}: {
+ simulation: FujifilmSimulation;
+}) {
+ const { small, medium, large } = getLabelForFilmSimulation(simulation);
+ return (
+
+ {small}
+ {medium}
+
+
+
+
+ );
+}
diff --git a/src/vendors/fujifilm/PhotoFujifilmSimulationIcon.tsx b/src/vendors/fujifilm/PhotoFujifilmSimulationIcon.tsx
new file mode 100644
index 00000000..ebb5977c
--- /dev/null
+++ b/src/vendors/fujifilm/PhotoFujifilmSimulationIcon.tsx
@@ -0,0 +1,142 @@
+/* eslint-disable max-len */
+import {
+ FujifilmSimulation,
+ getLabelForFilmSimulation,
+} from '@/vendors/fujifilm';
+
+export default function PhotoFujifilmSimulationIcon({
+ simulation,
+}: {
+ simulation: FujifilmSimulation;
+}) {
+ const contentForSimulation = (): JSX.Element => {
+ switch (simulation) {
+ case 'monochrome': return <>
+
+
+ >;
+ case 'monochrome-ye': return <>
+
+
+
+ >;
+ case 'monochrome-r': return <>
+
+
+
+ >;
+ case 'monochrome-g': return <>
+
+
+
+ >;
+ case 'sepia': return <>
+
+
+
+ >;
+ case 'acros': return <>
+
+
+ >;
+ case 'acros-ye': return <>
+
+
+
+ >;
+ case 'acros-r': return <>
+
+
+
+ >;
+ case 'acros-g': return <>
+
+
+
+ >;
+ case 'provia': return <>
+
+
+
+ >;
+ case 'portrait': return <>
+
+
+ >;
+ case 'portrait-saturation': return <>
+
+
+
+ >;
+ case 'portrait-skin-tone': return <>
+
+
+ >;
+ case 'portrait-sharpness': return <>
+
+
+
+ >;
+ case 'portrait-ex': return <>
+
+
+
+ >;
+ case 'velvia': return <>
+
+
+ >;
+ case 'pro-neg-std': return <>
+
+
+
+ >;
+ case 'pro-neg-hi': return <>
+
+
+
+ >;
+ case 'classic-chrome': return <>
+
+
+
+ >;
+ case 'eterna': return <>
+
+
+ >;
+ case 'classic-neg': return <>
+
+
+
+ >;
+ case 'eterna-bleach-bypass': return <>
+
+
+
+ >;
+ case 'nostalgic-neg': return <>
+
+
+
+ >;
+ case 'reala': return <>
+
+
+ >;
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/vendors/fujifilm/index.ts b/src/vendors/fujifilm/index.ts
new file mode 100644
index 00000000..edef8813
--- /dev/null
+++ b/src/vendors/fujifilm/index.ts
@@ -0,0 +1,270 @@
+// MakerNote tag IDs and values referenced from:
+// github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm
+
+import type { ExifData } from 'ts-exif-parser';
+
+const MAKE_FUJIFILM = 'FUJIFILM';
+
+const BYTE_INDEX_FIRST_TAG = 14;
+const BYTES_PER_TAG = 12;
+const BYTE_OFFSET_FOR_INT_VALUE = 8;
+
+const TAG_ID_SATURATION = 0x1003;
+const TAG_ID_FILM_MODE = 0x1401;
+
+type FujifilmSimulationFromSaturation =
+ 'monochrome' |
+ 'monochrome-ye' |
+ 'monochrome-r' |
+ 'monochrome-g' |
+ 'sepia' |
+ 'acros' |
+ 'acros-ye' |
+ 'acros-r' |
+ 'acros-g';
+
+type FujifilmMode =
+ 'provia' |
+ 'portrait' |
+ 'portrait-saturation' |
+ 'portrait-skin-tone' |
+ 'portrait-sharpness' |
+ 'portrait-ex' |
+ 'velvia' |
+ 'pro-neg-std' |
+ 'pro-neg-hi' |
+ 'classic-chrome' |
+ 'eterna' |
+ 'classic-neg' |
+ 'eterna-bleach-bypass' |
+ 'nostalgic-neg' |
+ 'reala';
+
+export type FujifilmSimulation =
+ FujifilmSimulationFromSaturation |
+ FujifilmMode;
+
+export const isExifForFujifilm = (data: ExifData) =>
+ data.tags?.Make === MAKE_FUJIFILM;
+
+const getFujifilmSimulationFromSaturation = (
+ value?: number,
+): FujifilmSimulationFromSaturation | undefined => {
+ switch (value) {
+ case 0x300: return 'monochrome';
+ case 0x301: return 'monochrome-r';
+ case 0x302: return 'monochrome-ye';
+ case 0x303: return 'monochrome-g';
+ case 0x310: return 'sepia';
+ case 0x500: return 'acros';
+ case 0x501: return 'acros-r';
+ case 0x502: return 'acros-ye';
+ case 0x503: return 'acros-g';
+ }
+};
+
+const getFujifilmMode = (
+ value?: number,
+): FujifilmMode | undefined => {
+ switch (value) {
+ case 0x000: return 'provia';
+ case 0x100: return 'portrait';
+ case 0x110: return 'portrait-saturation';
+ case 0x120: return 'portrait-skin-tone';
+ case 0x130: return 'portrait-sharpness';
+ case 0x300: return 'portrait-ex';
+ case 0x200:
+ case 0x400: return 'velvia';
+ case 0x500: return 'pro-neg-std';
+ case 0x501: return 'pro-neg-hi';
+ case 0x600: return 'classic-chrome';
+ case 0x700: return 'eterna';
+ case 0x800: return 'classic-neg';
+ case 0x900: return 'eterna-bleach-bypass';
+ case 0xa00: return 'nostalgic-neg';
+ case 0xb00: return 'reala';
+ }
+};
+
+interface FujifilmSimulationLabel {
+ small: string
+ medium: string
+ large: string
+}
+
+const FILM_SIMULATION_LABELS: Record<
+ FujifilmSimulation,
+ FujifilmSimulationLabel
+> = {
+ 'monochrome': {
+ small: 'Monochrome',
+ medium: 'Monochrome',
+ large: 'Monochrome',
+ },
+ 'monochrome-ye': {
+ small: 'Monochrome+Ye',
+ medium: 'Monochrome+Ye',
+ large: 'Monochrome + Yellow Filter',
+ },
+ 'monochrome-r': {
+ small: 'Monochrome+R',
+ medium: 'Monochrome+R',
+ large: 'Monochrome + Red Filter',
+ },
+ 'monochrome-g': {
+ small: 'Monochrome+G',
+ medium: 'Monochrome+G',
+ large: 'Monochrome + Green Filter',
+ },
+ 'sepia': {
+ small: 'Sepia',
+ medium: 'Sepia',
+ large: 'Sepia',
+ },
+ 'acros': {
+ small: 'ACROS',
+ medium: 'ACROS',
+ large: 'ACROS',
+ },
+ 'acros-ye': {
+ small: 'ACROS+Ye',
+ medium: 'ACROS+Ye',
+ large: 'ACROS + Yellow Filter',
+ },
+ 'acros-r': {
+ small: 'ACROS+R',
+ medium: 'ACROS+R',
+ large: 'ACROS + Red Filter',
+ },
+ 'acros-g': {
+ small: 'ACROS+G',
+ medium: 'ACROS+G',
+ large: 'ACROS + Green Filter',
+ },
+ 'provia': {
+ small: 'PROVIA',
+ medium: 'PROVIA/Std',
+ large: 'PROVIA / Standard',
+ },
+ 'portrait': {
+ small: 'Portrait',
+ medium: 'Portrait',
+ large: 'Studio Portrait',
+ },
+ 'portrait-saturation': {
+ small: 'Portrait+Sat.',
+ medium: 'Portrait+Sat.',
+ large: 'Studio Portrait + Enhanced Saturation',
+ },
+ 'portrait-skin-tone': {
+ small: 'ASTIA',
+ medium: 'ASTIA/Soft',
+ large: 'ASTIA / Soft',
+ },
+ 'portrait-sharpness': {
+ small: 'Portrait+Sharp.',
+ medium: 'Portrait+Sharp.',
+ large: 'Studio Portrait + Enhanced Sharpness',
+ },
+ 'portrait-ex': {
+ small: 'Portrait+Ex',
+ medium: 'Portrait+Ex',
+ large: 'Studio Portrait + Ex',
+ },
+ 'velvia': {
+ small: 'Velvia',
+ medium: 'Velvia/Vivid',
+ large: 'Velvia / Vivid',
+ },
+ 'pro-neg-std': {
+ small: 'PRO Neg. Std',
+ medium: 'PRO Neg. Std',
+ large: 'PRO Neg. Std',
+ },
+ 'pro-neg-hi': {
+ small: 'PRO Neg. Hi',
+ medium: 'PRO Neg. Hi',
+ large: 'PRO Neg. Hi',
+ },
+ 'classic-chrome': {
+ small: 'Classic Chrome',
+ medium: 'Classic Chrome',
+ large: 'Classic Chrome',
+ },
+ 'eterna': {
+ small: 'ETERNA',
+ medium: 'ETERNA/Cinema',
+ large: 'ETERNA / Cinema',
+ },
+ 'classic-neg': {
+ small: 'Classic Neg.',
+ medium: 'Classic Neg.',
+ large: 'Classic Neg.',
+ },
+ 'eterna-bleach-bypass': {
+ small: 'ETERNA Bypass',
+ medium: 'ETERNA Bypass',
+ large: 'ETERNA Bleach Bypass',
+ },
+ 'nostalgic-neg': {
+ small: 'Nostalgic Neg.',
+ medium: 'Nostalgic Neg.',
+ large: 'Nostalgic Neg.',
+ },
+ 'reala': {
+ small: 'REALA',
+ medium: 'REALA ACE',
+ large: 'REALA ACE',
+ },
+};
+
+export const FILM_SIMULATION_FORM_INPUT_OPTIONS = Object
+ .entries(FILM_SIMULATION_LABELS)
+ .map(([value, label]) => (
+ { value, label: label.large } as
+ { value: FujifilmSimulation, label: string }
+ ))
+ .sort((a, b) => a.label.localeCompare(b.label));
+
+export const getLabelForFilmSimulation = (
+ simulation: FujifilmSimulation
+) =>
+ FILM_SIMULATION_LABELS[simulation];
+
+const parseFujifilmMakerNote = (
+ bytes: Buffer,
+ valueForTag: (tag: number, value: number) => void
+) => {
+ for (
+ let i = BYTE_INDEX_FIRST_TAG;
+ i + BYTES_PER_TAG < bytes.length;
+ i += BYTES_PER_TAG
+ ) {
+ const tag = bytes.readUInt16LE(i);
+ const value = bytes.readUInt16LE(i + BYTE_OFFSET_FOR_INT_VALUE);
+ valueForTag(tag, value);
+ }
+};
+
+export const getFujifilmSimulationFromMakerNote = (
+ bytes: Buffer,
+): FujifilmSimulation | undefined => {
+ let filmModeFromSaturation: FujifilmSimulationFromSaturation | undefined;
+ let filmMode: FujifilmMode | undefined;
+
+ parseFujifilmMakerNote(
+ bytes,
+ (tag, value) => {
+ switch (tag) {
+ case TAG_ID_SATURATION:
+ filmModeFromSaturation = getFujifilmSimulationFromSaturation(value);
+ break;
+ case TAG_ID_FILM_MODE:
+ filmMode = getFujifilmMode(value);
+ break;
+ }
+ },
+ );
+
+ return filmModeFromSaturation ?? filmMode;
+};