commit
ea4a239304
@ -130,6 +130,7 @@ Application behavior can be changed by configuring the following environment var
|
||||
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
|
||||
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
|
||||
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results
|
||||
- `NEXT_PUBLIC_HIDE_RECIPES = 1` prevents Fujifilm recipe button showing up in photo meta
|
||||
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
|
||||
|
||||
#### Grid
|
||||
@ -282,6 +283,9 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
|
||||
#### Why aren't Fujifilm simulations importing alongside EXIF data?
|
||||
> Fujifilm simulation data is stored in vendor-specific Makernote binaries embedded in EXIF data. Under certain circumstances an intermediary may strip out this data. For instance, there is a known issue on iOS where editing an image, e.g., cropping it, causes Makernote data loss. If simulation data appears to be missing, try importing the original file as it was stored by the camera. Additionally, if you can confirm the simulation mode, you can edit the photo and manually select it.
|
||||
|
||||
#### My Fujifilm recipes are missing/displaying incorrect data. What should I do?
|
||||
> Fujifilm file specifications have evolved over time. Open an issue with the file in question attached in order for it to be investigated.
|
||||
|
||||
#### Why do my images appear flipped/rotated incorrectly?
|
||||
> For a number of reasons, only EXIF orientations: 1, 3, 6, and 8 are supported. Orientations 2, 4, 5, and 7—which make use of mirroring—are not supported.
|
||||
|
||||
|
||||
16
__tests__/fujifilm.test.ts
Normal file
16
__tests__/fujifilm.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { processTone } from '@/platforms/fujifilm/recipe';
|
||||
|
||||
describe('Fujifilm', () => {
|
||||
describe('recipes', () => {
|
||||
it('process tone', () => {
|
||||
expect(processTone(0)).toBe(0);
|
||||
expect(processTone(8)).toBe(-0.5);
|
||||
expect(processTone(16)).toBe(-1);
|
||||
expect(processTone(32)).toBe(-2);
|
||||
expect(processTone(-16)).toBe(1);
|
||||
expect(processTone(-32)).toBe(2);
|
||||
expect(processTone(-48)).toBe(3);
|
||||
expect(processTone(-64)).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,17 +1,12 @@
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { OUTDATED_THRESHOLD } from '@/photo';
|
||||
import AdminOutdatedClient from '@/admin/AdminOutdatedClient';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { getOutdatedPhotos } from '@/photo/db/query';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export default async function AdminOutdatedPage() {
|
||||
const photos = await getPhotos({
|
||||
hidden: 'include',
|
||||
sortBy: 'createdAtAsc',
|
||||
updatedBefore: OUTDATED_THRESHOLD,
|
||||
limit: 1_000,
|
||||
}).catch(() => []);
|
||||
const photos = await getOutdatedPhotos()
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<AdminOutdatedClient {...{
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache';
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { OUTDATED_THRESHOLD } from '@/photo';
|
||||
import AdminPhotosClient from '@/admin/AdminPhotosClient';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
|
||||
import { getOutdatedPhotosCount } from '@/photo/db/query';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
@ -31,11 +31,7 @@ export default async function AdminPhotosPage() {
|
||||
getPhotosMetaCached({ hidden: 'include'})
|
||||
.then(({ count }) => count)
|
||||
.catch(() => 0),
|
||||
getPhotosMetaCached({
|
||||
hidden: 'include',
|
||||
updatedBefore: OUTDATED_THRESHOLD,
|
||||
})
|
||||
.then(({ count }) => count)
|
||||
getOutdatedPhotosCount()
|
||||
.catch(() => 0),
|
||||
DEBUG_PHOTO_BLOBS
|
||||
? getStoragePhotoUrlsNoStore()
|
||||
|
||||
31
app/admin/recipe/[photoId]/page.tsx
Normal file
31
app/admin/recipe/[photoId]/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { getPhoto, getPhotos } from '@/photo/db/query';
|
||||
import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay';
|
||||
|
||||
export default async function AdminRecipePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ photoId: string }>
|
||||
}) {
|
||||
const { photoId } = await params;
|
||||
const photo = await getPhoto(photoId);
|
||||
const photosHidden = await getPhotos({ hidden: 'only' });
|
||||
const { filmSimulation } = photo!;
|
||||
const { fujifilmRecipe } = photosHidden[0];
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
contentMain={photo && fujifilmRecipe && filmSimulation
|
||||
? <PhotoRecipeOverlay
|
||||
backgroundImageUrl={photo.url}
|
||||
recipe={fujifilmRecipe}
|
||||
simulation={filmSimulation}
|
||||
exposure={photo.exposureCompensationFormatted ?? '+0ev'}
|
||||
iso={photo.isoFormatted ?? 'ISO 0'}
|
||||
/>
|
||||
: <div>
|
||||
Can't find photo/recipe
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
app/admin/recipe/page.tsx
Normal file
38
app/admin/recipe/page.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { getPhotos } from '@/photo/db/query';
|
||||
import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay';
|
||||
|
||||
export default async function AdminRecipePage() {
|
||||
const photos = await getPhotos({ tag: 'favs', limit: 4});
|
||||
const photosHidden = await getPhotos({ hidden: 'only', limit: 1 });
|
||||
const { filmSimulation } = photosHidden[0];
|
||||
const { fujifilmRecipe } = photosHidden[0];
|
||||
return (
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 w-full">
|
||||
{photos.map(photo =>
|
||||
<PhotoRecipeOverlay
|
||||
key={photo.id}
|
||||
backgroundImageUrl={photo.url}
|
||||
recipe={fujifilmRecipe!}
|
||||
simulation={filmSimulation!}
|
||||
exposure={photo.exposureCompensationFormatted ?? '+0ev'}
|
||||
iso={photo.isoFormatted ?? 'ISO 0'}
|
||||
/>,
|
||||
)}
|
||||
<PhotoRecipeOverlay
|
||||
key="black"
|
||||
className="bg-black"
|
||||
recipe={fujifilmRecipe!}
|
||||
simulation={filmSimulation!}
|
||||
exposure="+0ev"
|
||||
iso="ISO 0"
|
||||
/>
|
||||
<PhotoRecipeOverlay
|
||||
key="white"
|
||||
className="bg-white"
|
||||
recipe={fujifilmRecipe!}
|
||||
simulation={filmSimulation!}
|
||||
exposure="+0ev"
|
||||
iso="ISO 0"
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm';
|
||||
import PhotoFilmSimulation from
|
||||
'@/simulation/PhotoFilmSimulation';
|
||||
import {
|
||||
FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||
} from '@/platforms/fujifilm/simulation';
|
||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function FilmPage() {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm';
|
||||
import PhotoFilmSimulation from
|
||||
'@/simulation/PhotoFilmSimulation';
|
||||
import {
|
||||
FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||
} from '@/platforms/fujifilm/simulation';
|
||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
|
||||
export default function FilmPage() {
|
||||
return (
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"sanitize-html": "^2.14.0",
|
||||
"sharp": "^0.33.5",
|
||||
"sonner": "^2.0.0",
|
||||
"sonner": "^2.0.1",
|
||||
"swr": "^2.3.2",
|
||||
"ts-exif-parser": "^0.2.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -96,8 +96,8 @@ importers:
|
||||
specifier: ^0.33.5
|
||||
version: 0.33.5
|
||||
sonner:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
swr:
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2(react@19.0.0)
|
||||
@ -3859,8 +3859,8 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
sonner@2.0.0:
|
||||
resolution: {integrity: sha512-3WeSl3WrEdhmdiTniT8JsCiVRVDOdn7k+4MG2drqzSMOeqrExm03HIwDBPKuq52JBqL7wRLG17QBIiSleUbljw==}
|
||||
sonner@2.0.1:
|
||||
resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
@ -8805,7 +8805,7 @@ snapshots:
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
sonner@2.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
@ -73,6 +73,7 @@ export default function AdminAppConfigurationClient({
|
||||
showTakenAtTimeHidden,
|
||||
showSocial,
|
||||
showFilmSimulations,
|
||||
showRecipes,
|
||||
showRepoLink,
|
||||
// Grid
|
||||
isGridHomepageEnabled,
|
||||
@ -525,6 +526,15 @@ export default function AdminAppConfigurationClient({
|
||||
CMD-K results:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show Fujifilm recipes"
|
||||
status={showRecipes}
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to prevent
|
||||
Fujifilm recipe button showing up in photo meta:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_RECIPES'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show repo link"
|
||||
status={showRepoLink}
|
||||
|
||||
@ -17,22 +17,22 @@ export default function AdminLink({
|
||||
{...props}
|
||||
href={href}
|
||||
target="blank"
|
||||
className={clsx(
|
||||
className={className}
|
||||
>
|
||||
<span className={clsx(
|
||||
'underline underline-offset-4',
|
||||
'decoration-gray-300 dark:decoration-gray-700',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
{externalIcon && <span className="whitespace-nowrap">
|
||||
|
||||
<FiExternalLink
|
||||
size={14}
|
||||
className="inline translate-y-[-1.5px]"
|
||||
/>
|
||||
</span>}
|
||||
</Link>
|
||||
{externalIcon &&
|
||||
<>
|
||||
|
||||
<FiExternalLink
|
||||
size={14}
|
||||
className="inline translate-y-[-1.5px]"
|
||||
/>
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { OUTDATED_THRESHOLD, Photo } from '@/photo';
|
||||
import { Photo } from '@/photo';
|
||||
import AdminPhotosTable from '@/admin/AdminPhotosTable';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import IconGrSync from '@/app/IconGrSync';
|
||||
@ -80,18 +80,13 @@ export default function AdminOutdatedClient({
|
||||
<Note>
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-bold">
|
||||
Outdated photos found
|
||||
{photos.length} outdated
|
||||
{' '}
|
||||
{photos.length === 1 ? 'photo' : 'photos'} found
|
||||
</div>
|
||||
{photos.length}
|
||||
They may have missing EXIF fields, inaccurate blur data,
|
||||
{' '}
|
||||
{photos.length === 1 ? 'photo' : 'photos'}
|
||||
{' ('}last updated before
|
||||
{' '}
|
||||
{new Date(OUTDATED_THRESHOLD).toLocaleDateString()}{')'}
|
||||
{' '}
|
||||
may have: missing EXIF fields, inaccurate blur data,
|
||||
{' '}
|
||||
undesired privacy settings, or missing AI-generated text
|
||||
undesired privacy settings, or text that can be AI-generated
|
||||
</div>
|
||||
</Note>
|
||||
<div className="space-y-4">
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
|
||||
import { ComponentProps, useMemo } from 'react';
|
||||
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
||||
import { deletePhotoAction, toggleFavoritePhotoAction } from '@/photo/actions';
|
||||
import {
|
||||
deletePhotoAction,
|
||||
syncPhotoAction,
|
||||
toggleFavoritePhotoAction,
|
||||
} from '@/photo/actions';
|
||||
import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa';
|
||||
import {
|
||||
Photo,
|
||||
@ -17,6 +21,9 @@ import { useAppState } from '@/state/AppState';
|
||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||
import { MdOutlineFileDownload } from 'react-icons/md';
|
||||
import MoreMenuItem from '@/components/more/MoreMenuItem';
|
||||
import IconGrSync from '@/app/IconGrSync';
|
||||
import { isPhotoOutdated } from '@/photo/outdated';
|
||||
import { FaCircle } from 'react-icons/fa6';
|
||||
|
||||
export default function AdminPhotoMenuClient({
|
||||
photo,
|
||||
@ -65,17 +72,31 @@ export default function AdminPhotoMenuClient({
|
||||
label: 'Download',
|
||||
icon: <MdOutlineFileDownload
|
||||
size={17}
|
||||
className="translate-x-[-1.5px] translate-y-[-0.5px]"
|
||||
className="translate-x-[-1px] translate-y-[-0.5px]"
|
||||
/>,
|
||||
href: photo.url,
|
||||
hrefDownloadName: downloadFileNameForPhoto(photo),
|
||||
});
|
||||
items.push({
|
||||
label: <span className="inline-flex items-center gap-2">
|
||||
<span>Sync</span>
|
||||
{isPhotoOutdated(photo) &&
|
||||
<FaCircle
|
||||
size={8}
|
||||
className="text-amber-500 translate-y-[1.5px]"
|
||||
/>}
|
||||
</span>,
|
||||
icon: <IconGrSync className="translate-x-[-1px]" />,
|
||||
action: () => syncPhotoAction(photo.id)
|
||||
.then(() => revalidatePhoto?.(photo.id)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Delete',
|
||||
icon: <BiTrash
|
||||
size={15}
|
||||
className="translate-x-[-1.5px]"
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
className: 'text-error',
|
||||
action: () => {
|
||||
if (confirm(deleteConfirmationTextForPhoto(photo))) {
|
||||
return deletePhotoAction(
|
||||
|
||||
@ -12,8 +12,8 @@ import {
|
||||
HAS_STATIC_OPTIMIZATION,
|
||||
MATTE_PHOTOS,
|
||||
} from '@/app/config';
|
||||
import { OUTDATED_THRESHOLD } from '@/photo';
|
||||
import { getGitHubMetaForCurrentApp, getSignificantInsights } from '.';
|
||||
import { getOutdatedPhotosCount } from '@/photo/db/query';
|
||||
|
||||
const BASIC_PHOTO_INSTALLATION_COUNT = 32;
|
||||
|
||||
@ -21,7 +21,7 @@ export default async function AdminAppInsights() {
|
||||
const [
|
||||
{ count: photosCount, dateRange },
|
||||
{ count: photosCountHidden },
|
||||
{ count: photosCountOutdated },
|
||||
photosCountOutdated,
|
||||
{ count: photosCountPortrait },
|
||||
tags,
|
||||
cameras,
|
||||
@ -31,7 +31,7 @@ export default async function AdminAppInsights() {
|
||||
] = await Promise.all([
|
||||
getPhotosMeta({ hidden: 'include' }),
|
||||
getPhotosMeta({ hidden: 'only' }),
|
||||
getPhotosMeta({ hidden: 'include', updatedBefore: OUTDATED_THRESHOLD }),
|
||||
getOutdatedPhotosCount(),
|
||||
getPhotosMeta({ maximumAspectRatio: 0.9 }),
|
||||
getUniqueTags(),
|
||||
getUniqueCameras(),
|
||||
|
||||
@ -342,8 +342,10 @@ export default function AdminAppInsightsClient({
|
||||
)}
|
||||
/>}
|
||||
content={renderHighlightText(
|
||||
// eslint-disable-next-line max-len
|
||||
pluralize(photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED, 'outdated photo'),
|
||||
pluralize(
|
||||
photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED,
|
||||
'outdated photo',
|
||||
),
|
||||
'yellow',
|
||||
)}
|
||||
expandPath={PATH_ADMIN_OUTDATED}
|
||||
|
||||
@ -6,18 +6,17 @@ import {
|
||||
getSignificantInsights,
|
||||
InsightIndicatorStatus,
|
||||
} from '.';
|
||||
import { getPhotosMeta } from '@/photo/db/query';
|
||||
import { OUTDATED_THRESHOLD } from '@/photo';
|
||||
import { getOutdatedPhotosCount } from '@/photo/db/query';
|
||||
|
||||
export const getShouldShowInsightsIndicatorAction =
|
||||
async (): Promise<InsightIndicatorStatus> =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const [
|
||||
codeMeta,
|
||||
{ count: photosCountOutdated },
|
||||
photosCountOutdated,
|
||||
] = await Promise.all([
|
||||
getGitHubMetaForCurrentApp(),
|
||||
getPhotosMeta({ hidden: 'include', updatedBefore: OUTDATED_THRESHOLD }),
|
||||
getOutdatedPhotosCount(),
|
||||
]);
|
||||
|
||||
const {
|
||||
|
||||
@ -18,7 +18,7 @@ import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { ADMIN_DEBUG_TOOLS_ENABLED, SHOW_FILM_SIMULATIONS } from './config';
|
||||
import { labelForFilmSimulation } from '@/platforms/fujifilm';
|
||||
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import { getUniqueFocalLengths } from '@/photo/db/query';
|
||||
import { formatFocalLength } from '@/focal';
|
||||
import { TbCone } from 'react-icons/tb';
|
||||
|
||||
@ -217,6 +217,8 @@ export const SHOW_SOCIAL =
|
||||
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
|
||||
export const SHOW_FILM_SIMULATIONS =
|
||||
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
|
||||
export const SHOW_RECIPES =
|
||||
process.env.NEXT_PUBLIC_HIDE_RECIPES !== '1';
|
||||
export const SHOW_REPO_LINK =
|
||||
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
|
||||
|
||||
@ -313,6 +315,7 @@ export const APP_CONFIGURATION = {
|
||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||
showSocial: SHOW_SOCIAL,
|
||||
showFilmSimulations: SHOW_FILM_SIMULATIONS,
|
||||
showRecipes: SHOW_RECIPES,
|
||||
showRepoLink: SHOW_REPO_LINK,
|
||||
// Grid
|
||||
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
|
||||
|
||||
@ -33,6 +33,10 @@ const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
|
||||
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
|
||||
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
||||
|
||||
// Search params
|
||||
export const SEARCH_PARAM_SHOW = 'show';
|
||||
export const SEARCH_PARAM_SHOW_RECIPE = 'recipe';
|
||||
|
||||
// Admin paths
|
||||
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
||||
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
|
||||
@ -78,7 +82,9 @@ export const PATHS_TO_CACHE = [
|
||||
...PATHS_ADMIN,
|
||||
];
|
||||
|
||||
type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory;
|
||||
type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory & {
|
||||
showRecipe?: boolean
|
||||
};
|
||||
|
||||
// Absolute paths
|
||||
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
|
||||
@ -103,8 +109,9 @@ export const pathForPhoto = ({
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
}: PhotoPathParams) =>
|
||||
typeof photo !== 'string' && photo.hidden
|
||||
showRecipe,
|
||||
}: PhotoPathParams) => {
|
||||
const path = typeof photo !== 'string' && photo.hidden
|
||||
? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
|
||||
: tag
|
||||
? `${pathForTag(tag)}/${getPhotoId(photo)}`
|
||||
@ -115,6 +122,10 @@ export const pathForPhoto = ({
|
||||
: focal
|
||||
? `${pathForFocalLength(focal)}/${getPhotoId(photo)}`
|
||||
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
|
||||
return showRecipe
|
||||
? `${path}?${SEARCH_PARAM_SHOW}=${SEARCH_PARAM_SHOW_RECIPE}`
|
||||
: path;
|
||||
};
|
||||
|
||||
export const pathForTag = (tag: string) =>
|
||||
`${PREFIX_TAG}/${tag}`;
|
||||
|
||||
@ -24,8 +24,9 @@ export type CameraWithCount = {
|
||||
|
||||
export type Cameras = CameraWithCount[];
|
||||
|
||||
export const createCameraKey = ({ make, model }: Camera) =>
|
||||
parameterize(`${make}-${model}`, true);
|
||||
// Support keys for make-only and model-only camera queries
|
||||
export const createCameraKey = ({ make, model }: Partial<Camera>) =>
|
||||
parameterize(`${make ?? 'ANY'}-${model ?? 'ANY'}`, true);
|
||||
|
||||
export const getCameraFromParams = ({
|
||||
make,
|
||||
|
||||
@ -5,7 +5,7 @@ export default function Badge({
|
||||
className,
|
||||
type = 'large',
|
||||
dimContent,
|
||||
highContrast,
|
||||
contrast = 'low',
|
||||
uppercase,
|
||||
interactive,
|
||||
}: {
|
||||
@ -13,7 +13,7 @@ export default function Badge({
|
||||
className?: string
|
||||
type?: 'large' | 'small' | 'text-only'
|
||||
dimContent?: boolean
|
||||
highContrast?: boolean
|
||||
contrast?: 'low' | 'medium' | 'high' | 'frosted'
|
||||
uppercase?: boolean
|
||||
interactive?: boolean
|
||||
}) {
|
||||
@ -30,13 +30,15 @@ export default function Badge({
|
||||
return clsx(
|
||||
'px-[5px] h-[17px] md:h-[18px]',
|
||||
'text-[0.7rem] font-medium rounded-[0.25rem]',
|
||||
highContrast
|
||||
contrast === 'high'
|
||||
? 'text-invert bg-invert'
|
||||
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
|
||||
interactive && (highContrast
|
||||
: contrast === 'frosted'
|
||||
? 'text-black bg-neutral-100/30 border border-neutral-200/40'
|
||||
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
|
||||
interactive && (contrast === 'high'
|
||||
? 'hover:opacity-70'
|
||||
: 'hover:text-gray-900 dark:hover:text-gray-100'),
|
||||
interactive && (highContrast
|
||||
interactive && (contrast === 'high'
|
||||
? 'active:opacity-90'
|
||||
: 'active:bg-gray-200 dark:active:bg-gray-700/60'),
|
||||
);
|
||||
|
||||
@ -31,7 +31,7 @@ export default function Container({
|
||||
case 'blue': return [
|
||||
'text-blue-900 dark:text-blue-300',
|
||||
'bg-blue-50/50 dark:bg-blue-950/30',
|
||||
'border-blue-200 dark:border-blue-600/20',
|
||||
'border-blue-200 dark:border-blue-500/40',
|
||||
];
|
||||
case 'red': return [
|
||||
'text-red-600 dark:text-red-500/90',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { JSX, RefObject } from 'react';
|
||||
import { HTMLAttributes, JSX, RefObject } from 'react';
|
||||
|
||||
/*
|
||||
MAX WIDTHS
|
||||
@ -18,6 +18,7 @@ export default function SiteGrid({
|
||||
contentSide,
|
||||
sideFirstOnMobile,
|
||||
sideHiddenOnMobile,
|
||||
...props
|
||||
}: {
|
||||
containerRef?: RefObject<HTMLDivElement | null>
|
||||
className?: string
|
||||
@ -25,9 +26,10 @@ export default function SiteGrid({
|
||||
contentSide?: JSX.Element
|
||||
sideFirstOnMobile?: boolean
|
||||
sideHiddenOnMobile?: boolean
|
||||
}) {
|
||||
} & HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'grid',
|
||||
|
||||
@ -112,6 +112,7 @@ export default function CommandKClient({
|
||||
shouldShowBaselineGrid,
|
||||
shouldDebugImageFallbacks,
|
||||
shouldDebugInsights,
|
||||
shouldDebugRecipeOverlays,
|
||||
setIsCommandKOpen: setIsOpen,
|
||||
setShouldRespondToKeyboardCommands,
|
||||
setShouldShowBaselineGrid,
|
||||
@ -120,6 +121,7 @@ export default function CommandKClient({
|
||||
setArePhotosMatted,
|
||||
setShouldDebugImageFallbacks,
|
||||
setShouldDebugInsights,
|
||||
setShouldDebugRecipeOverlays,
|
||||
} = useAppState();
|
||||
|
||||
const isOpenRef = useRef(isOpen);
|
||||
@ -299,6 +301,11 @@ export default function CommandKClient({
|
||||
setShouldDebugInsights,
|
||||
shouldDebugInsights,
|
||||
),
|
||||
renderToggle(
|
||||
'Recipe Overlays',
|
||||
setShouldDebugRecipeOverlays,
|
||||
shouldDebugRecipeOverlays,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ export default function MoreMenuItem({
|
||||
icon,
|
||||
href,
|
||||
hrefDownloadName,
|
||||
className,
|
||||
action,
|
||||
shouldPreventDefault = true,
|
||||
}: {
|
||||
@ -19,6 +20,7 @@ export default function MoreMenuItem({
|
||||
icon?: ReactNode
|
||||
href?: string
|
||||
hrefDownloadName?: string
|
||||
className?: string
|
||||
action?: () => Promise<void> | void
|
||||
shouldPreventDefault?: boolean
|
||||
}) {
|
||||
@ -43,6 +45,7 @@ export default function MoreMenuItem({
|
||||
isLoading
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
onClick={async e => {
|
||||
if (shouldPreventDefault) { e.preventDefault(); }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
|
||||
import Badge from '../Badge';
|
||||
import { clsx } from 'clsx/lite';
|
||||
@ -10,7 +10,7 @@ import Spinner from '../Spinner';
|
||||
export interface EntityLinkExternalProps {
|
||||
type?: LabeledIconType
|
||||
badged?: boolean
|
||||
contrast?: 'low' | 'medium' | 'high'
|
||||
contrast?: ComponentProps<typeof Badge>['contrast']
|
||||
prefetch?: boolean
|
||||
}
|
||||
|
||||
@ -48,6 +48,8 @@ export default function EntityLink({
|
||||
return 'text-dim';
|
||||
case 'high':
|
||||
return 'text-main';
|
||||
case 'frosted':
|
||||
return 'text-black';
|
||||
default:
|
||||
return 'text-medium';
|
||||
}
|
||||
@ -88,7 +90,7 @@ export default function EntityLink({
|
||||
{badged
|
||||
? <Badge
|
||||
type="small"
|
||||
highContrast={contrast === 'high'}
|
||||
contrast={contrast}
|
||||
className='translate-y-[-0.5px]'
|
||||
uppercase
|
||||
interactive
|
||||
@ -105,9 +107,14 @@ export default function EntityLink({
|
||||
<span className="hidden group-hover:inline text-dim">
|
||||
{hoverEntity}
|
||||
</span>}
|
||||
{isLoading && <Spinner className={clsx(
|
||||
badged && 'translate-y-[0.5px]',
|
||||
)} />}
|
||||
{isLoading &&
|
||||
<Spinner
|
||||
className={clsx(
|
||||
badged && 'translate-y-[0.5px]',
|
||||
contrast === 'frosted' && 'text-neutral-500',
|
||||
)}
|
||||
color={contrast === 'frosted' ? 'text' : undefined}
|
||||
/>}
|
||||
</>}
|
||||
</LinkWithStatus>
|
||||
</span>
|
||||
|
||||
@ -4,7 +4,7 @@ import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import {
|
||||
labelForFilmSimulation,
|
||||
} from '@/platforms/fujifilm';
|
||||
} from '@/platforms/fujifilm/simulation';
|
||||
import PhotoFilmSimulationIcon from
|
||||
'@/simulation/PhotoFilmSimulationIcon';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
|
||||
@ -12,10 +12,7 @@ import SiteGrid from '@/components/SiteGrid';
|
||||
import ImageLarge from '@/components/image/ImageLarge';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
pathForFocalLength,
|
||||
pathForPhoto,
|
||||
} from '@/app/paths';
|
||||
import { pathForFocalLength, pathForPhoto } from '@/app/paths';
|
||||
import PhotoTags from '@/tag/PhotoTags';
|
||||
import ShareButton from '@/share/ShareButton';
|
||||
import DownloadButton from '@/components/DownloadButton';
|
||||
@ -40,6 +37,11 @@ import { LuExpand } from 'react-icons/lu';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
|
||||
import PhotoRecipe from './PhotoRecipe';
|
||||
import { TbChecklist } from 'react-icons/tb';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import useRecipeState from './useRecipeState';
|
||||
|
||||
export default function PhotoLarge({
|
||||
photo,
|
||||
@ -91,11 +93,23 @@ export default function PhotoLarge({
|
||||
const {
|
||||
areZoomControlsShown,
|
||||
arePhotosMatted,
|
||||
shouldDebugRecipeOverlays,
|
||||
isUserSignedIn,
|
||||
} = useAppState();
|
||||
|
||||
const showZoomControls = showZoomControlsProp && areZoomControlsShown;
|
||||
|
||||
const refRecipe = useRef<HTMLDivElement>(null);
|
||||
const refRecipeTrigger = useRef<HTMLButtonElement>(null);
|
||||
const {
|
||||
shouldShowRecipe,
|
||||
toggleRecipe,
|
||||
hideRecipe,
|
||||
} = useRecipeState({
|
||||
ref: refRecipe,
|
||||
refTrigger: refRecipeTrigger,
|
||||
});
|
||||
|
||||
const tags = sortTags(photo.tags, primaryTag);
|
||||
|
||||
const camera = cameraFromPhoto(photo);
|
||||
@ -142,6 +156,7 @@ export default function PhotoLarge({
|
||||
|
||||
const largePhotoContent =
|
||||
<div className={clsx(
|
||||
'relative',
|
||||
arePhotosMatted && 'flex items-center justify-center',
|
||||
// Always specify height to ensure fallback doesn't collapse
|
||||
arePhotosMatted && 'h-[90%]',
|
||||
@ -163,6 +178,24 @@ export default function PhotoLarge({
|
||||
priority={priority}
|
||||
/>
|
||||
</ZoomControls>
|
||||
<div className={clsx(
|
||||
'absolute inset-0',
|
||||
'flex items-center justify-center',
|
||||
)}>
|
||||
<AnimatePresence>
|
||||
{(shouldShowRecipe || shouldDebugRecipeOverlays) &&
|
||||
photo.fujifilmRecipe &&
|
||||
photo.filmSimulation &&
|
||||
<PhotoRecipe
|
||||
ref={refRecipe}
|
||||
recipe={photo.fujifilmRecipe}
|
||||
simulation={photo.filmSimulation}
|
||||
iso={photo.isoFormatted}
|
||||
exposure={photo.exposureCompensationFormatted}
|
||||
onClose={hideRecipe}
|
||||
/>}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const largePhotoContainerClassName = clsx(arePhotosMatted &&
|
||||
@ -275,10 +308,30 @@ export default function PhotoLarge({
|
||||
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
|
||||
</ul>
|
||||
{showSimulation && photo.filmSimulation &&
|
||||
<PhotoFilmSimulation
|
||||
simulation={photo.filmSimulation}
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>}
|
||||
<div className="flex items-center gap-2 *:w-auto">
|
||||
<PhotoFilmSimulation
|
||||
simulation={photo.filmSimulation}
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>
|
||||
{photo.fujifilmRecipe &&
|
||||
<button
|
||||
ref={refRecipeTrigger}
|
||||
title="Fujifilm Recipe"
|
||||
onClick={toggleRecipe}
|
||||
className={clsx(
|
||||
'text-medium',
|
||||
'border-medium rounded-md',
|
||||
'px-[4px] py-[2.5px] my-[-2.5px]',
|
||||
'hover:bg-dim active:bg-main',
|
||||
)}>
|
||||
{shouldShowRecipe
|
||||
? <IoCloseSharp size={15} />
|
||||
: <TbChecklist
|
||||
className="translate-x-[0.5px]"
|
||||
size={15}
|
||||
/>}
|
||||
</button>}
|
||||
</div>}
|
||||
</>}
|
||||
<div className={clsx(
|
||||
'flex gap-x-3 gap-y-baseline',
|
||||
@ -292,9 +345,8 @@ export default function PhotoLarge({
|
||||
// Prevent collision with admin button
|
||||
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
|
||||
)}
|
||||
// 'createdAt' is a naive datetime which
|
||||
// does not require a timezone and will not
|
||||
// cause server/client time mismatches
|
||||
// 'createdAt' is a naive datetime which does not require
|
||||
// a timezone and will not cause server/client mismatch
|
||||
timezone={null}
|
||||
hideTime={!SHOW_TAKEN_AT_TIME}
|
||||
/>
|
||||
|
||||
158
src/photo/PhotoRecipe.tsx
Normal file
158
src/photo/PhotoRecipe.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
import clsx from 'clsx/lite';
|
||||
import { ReactNode, RefObject } from 'react';
|
||||
import { IoCloseCircle } from 'react-icons/io5';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
|
||||
|
||||
export default function PhotoRecipe({
|
||||
ref,
|
||||
recipe: {
|
||||
dynamicRange,
|
||||
whiteBalance,
|
||||
highISONoiseReduction,
|
||||
noiseReductionBasic,
|
||||
highlight,
|
||||
shadow,
|
||||
color,
|
||||
sharpness,
|
||||
clarity,
|
||||
colorChromeEffect,
|
||||
colorChromeFXBlue,
|
||||
grainEffect,
|
||||
bwAdjustment,
|
||||
bwMagentaGreen,
|
||||
},
|
||||
simulation,
|
||||
iso,
|
||||
exposure,
|
||||
onClose,
|
||||
}: {
|
||||
ref?: RefObject<HTMLDivElement | null>
|
||||
recipe: FujifilmRecipe
|
||||
simulation: FilmSimulation
|
||||
iso?: string
|
||||
exposure?: string
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const whiteBalanceTypeFormatted = whiteBalance.type
|
||||
.replace(/auto./i, '')
|
||||
.replaceAll('-', ' ');
|
||||
|
||||
const renderRow = (children: ReactNode) =>
|
||||
<div className="flex gap-2 *:w-full *:grow">{children}</div>;
|
||||
|
||||
const renderDataSquare = (
|
||||
value: ReactNode,
|
||||
label?: string,
|
||||
className?: string,
|
||||
) => (
|
||||
<div className={clsx(
|
||||
'flex flex-col items-center justify-center gap-0.5 rounded-md min-w-0',
|
||||
'rounded-md border',
|
||||
'border-neutral-200/40',
|
||||
'bg-neutral-100/30 hover:bg-neutral-100/50',
|
||||
label && 'p-1',
|
||||
className,
|
||||
)}>
|
||||
<div className="truncate max-w-full tracking-wide">
|
||||
{typeof value === 'number' ? addSign(value) : value}
|
||||
</div>
|
||||
{label && <div className={clsx(
|
||||
'text-[10px] leading-none tracking-wide font-medium text-black/50',
|
||||
'uppercase',
|
||||
)}>
|
||||
{label}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, translateY: -10 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
exit={{ opacity: 0, translateY: -10 }}
|
||||
className={clsx(
|
||||
'z-10',
|
||||
'w-[19rem] p-3 space-y-3',
|
||||
'rounded-lg shadow-2xl',
|
||||
'text-[13.5px] text-black',
|
||||
'bg-white/70 border border-neutral-200/30',
|
||||
'backdrop-blur-xl saturate-[300%]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PhotoFilmSimulation
|
||||
contrast="frosted"
|
||||
className="grow"
|
||||
simulation={simulation}
|
||||
/>
|
||||
<LoaderButton
|
||||
icon={<IoCloseCircle size={20} />}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
'link p-0 m-0 h-4!',
|
||||
'text-black/40 active:text-black/75',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{renderRow(<>
|
||||
{renderDataSquare(`DR${dynamicRange.development}`)}
|
||||
{renderDataSquare(iso)}
|
||||
{renderDataSquare(exposure ?? '0ev')}
|
||||
</>)}
|
||||
{renderRow(<>
|
||||
{renderDataSquare(
|
||||
whiteBalanceTypeFormatted.toUpperCase(),
|
||||
`R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`,
|
||||
'basis-2/3',
|
||||
)}
|
||||
{renderDataSquare(
|
||||
highISONoiseReduction ?? noiseReductionBasic ?? 'OFF',
|
||||
'ISO NR',
|
||||
'basis-1/3',
|
||||
)}
|
||||
</>)}
|
||||
{renderRow(<>
|
||||
{renderDataSquare(highlight, 'Highlight')}
|
||||
{renderDataSquare(shadow, 'Shadow')}
|
||||
</>)}
|
||||
{renderRow(<>
|
||||
{renderDataSquare(color, 'Color')}
|
||||
{renderDataSquare(sharpness, 'Sharpness')}
|
||||
{renderDataSquare(clarity, 'Clarity')}
|
||||
</>)}
|
||||
{renderRow(<>
|
||||
{renderDataSquare(
|
||||
colorChromeEffect?.toLocaleUpperCase() ?? 'N/A',
|
||||
'Color Chrome',
|
||||
)}
|
||||
{renderDataSquare(
|
||||
colorChromeFXBlue?.toLocaleUpperCase() ?? 'N/A',
|
||||
'FX Blue',
|
||||
)}
|
||||
</>)}
|
||||
{renderRow(<>
|
||||
{renderDataSquare(
|
||||
grainEffect.roughness.toLocaleUpperCase(),
|
||||
grainEffect.size === 'large'
|
||||
? 'Large Grain'
|
||||
: grainEffect.size === 'small'
|
||||
? 'Small Grain'
|
||||
: 'Grain',
|
||||
)}
|
||||
{renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')}
|
||||
{renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')}
|
||||
</>)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
45
src/photo/PhotoRecipeOverlay.tsx
Normal file
45
src/photo/PhotoRecipeOverlay.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import clsx from 'clsx/lite';
|
||||
import ImageLarge from '@/components/image/ImageLarge';
|
||||
import PhotoRecipe from './PhotoRecipe';
|
||||
|
||||
export default function PhotoRecipeOverlay({
|
||||
backgroundImageUrl,
|
||||
recipe,
|
||||
simulation,
|
||||
exposure,
|
||||
iso,
|
||||
className,
|
||||
}: {
|
||||
backgroundImageUrl?: string
|
||||
recipe: FujifilmRecipe
|
||||
simulation: FilmSimulation
|
||||
exposure: string
|
||||
iso: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'relative w-full aspect-[3/2]',
|
||||
className,
|
||||
)}>
|
||||
{backgroundImageUrl &&<ImageLarge
|
||||
src={backgroundImageUrl}
|
||||
alt="Image Background"
|
||||
aspectRatio={3 / 2}
|
||||
/>}
|
||||
<div className={clsx(
|
||||
'absolute inset-0',
|
||||
'flex items-center justify-center',
|
||||
)}>
|
||||
<PhotoRecipe {...{
|
||||
recipe,
|
||||
simulation,
|
||||
exposure,
|
||||
iso,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -362,6 +362,8 @@ export const syncPhotoAction = async (photoId: string) =>
|
||||
const photoFormDbInsert = convertFormDataToPhotoDbInsert({
|
||||
...convertPhotoToFormData(photo),
|
||||
...photoFormExif,
|
||||
// Don't overwrite manually configured film simulations
|
||||
...photo.filmSimulation && { filmSimulation: photo.filmSimulation },
|
||||
...!BLUR_ENABLED && { blurData: undefined },
|
||||
...!photo.title && { title: atTitle },
|
||||
...!photo.caption && { caption: aiCaption },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { PRIORITY_ORDER_ENABLED } from '@/app/config';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { PhotoSetCategory } from '..';
|
||||
import { Camera } from '@/camera';
|
||||
|
||||
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
||||
export const PHOTO_DEFAULT_LIMIT = 100;
|
||||
@ -22,7 +23,9 @@ export type GetPhotosOptions = {
|
||||
takenAfterInclusive?: Date
|
||||
updatedBefore?: Date
|
||||
hidden?: 'exclude' | 'include' | 'only'
|
||||
} & PhotoSetCategory;
|
||||
} & Omit<PhotoSetCategory, 'camera'> & {
|
||||
camera?: Partial<Camera>
|
||||
};
|
||||
|
||||
export const areOptionsSensitive = (options: GetPhotosOptions) =>
|
||||
options.hidden === 'include' || options.hidden === 'only';
|
||||
@ -83,9 +86,11 @@ export const getWheresFromOptions = (
|
||||
wheres.push(`$${valuesIndex++}=ANY(tags)`);
|
||||
wheresValues.push(tag);
|
||||
}
|
||||
if (camera) {
|
||||
if (camera?.make) {
|
||||
wheres.push(`${parameterizeForDb('make')}=$${valuesIndex++}`);
|
||||
wheresValues.push(parameterize(camera.make, true));
|
||||
}
|
||||
if (camera?.model) {
|
||||
wheres.push(`${parameterizeForDb('model')}=$${valuesIndex++}`);
|
||||
wheresValues.push(parameterize(camera.model, true));
|
||||
}
|
||||
|
||||
40
src/photo/db/migration.ts
Normal file
40
src/photo/db/migration.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { sql } from '@/platforms/postgres';
|
||||
|
||||
interface Migration {
|
||||
label: string
|
||||
fields: string[]
|
||||
run: () => ReturnType<typeof sql>
|
||||
}
|
||||
|
||||
export const MIGRATIONS: Migration[] = [{
|
||||
label: '01: AI Text Generation',
|
||||
fields: ['caption', 'semantic_description'],
|
||||
run: () => sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS caption TEXT,
|
||||
ADD COLUMN IF NOT EXISTS semantic_description TEXT
|
||||
`,
|
||||
}, {
|
||||
label: '02: Lens Metadata',
|
||||
fields: ['lens_make', 'lens_model'],
|
||||
run: () => sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS lens_make VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255)
|
||||
`,
|
||||
}, {
|
||||
label: '03: Fujifilm Recipe',
|
||||
fields: ['fujifilm_recipe'],
|
||||
run: () => sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS fujifilm_recipe JSONB
|
||||
`,
|
||||
}];
|
||||
|
||||
export const migrationForError = (e: any) =>
|
||||
MIGRATIONS.find(migration =>
|
||||
migration.fields.some(field =>
|
||||
new RegExp(`column "${field}" of relation "photos" does not exist`, 'i')
|
||||
.test(e.message),
|
||||
),
|
||||
);
|
||||
@ -23,6 +23,9 @@ import {
|
||||
import { getWheresFromOptions } from '.';
|
||||
import { FocalLengths } from '@/focal';
|
||||
import { Lenses, createLensKey } from '@/lens';
|
||||
import { migrationForError } from './migration';
|
||||
import { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../outdated';
|
||||
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||
|
||||
const createPhotosTable = () =>
|
||||
sql`
|
||||
@ -50,6 +53,7 @@ const createPhotosTable = () =>
|
||||
latitude DOUBLE PRECISION,
|
||||
longitude DOUBLE PRECISION,
|
||||
film_simulation VARCHAR(255),
|
||||
fujifilm_recipe JSONB,
|
||||
priority_order REAL,
|
||||
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
taken_at_naive VARCHAR(255) NOT NULL,
|
||||
@ -59,24 +63,6 @@ const createPhotosTable = () =>
|
||||
)
|
||||
`;
|
||||
|
||||
// Migration 01
|
||||
const MIGRATION_FIELDS_01 = ['caption', 'semantic_description'];
|
||||
const runMigration01 = () =>
|
||||
sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS caption TEXT,
|
||||
ADD COLUMN IF NOT EXISTS semantic_description TEXT
|
||||
`;
|
||||
|
||||
// Migration 02
|
||||
const MIGRATION_FIELDS_02 = ['lens_make', 'lens_model'];
|
||||
const runMigration02 = () =>
|
||||
sql`
|
||||
ALTER TABLE photos
|
||||
ADD COLUMN IF NOT EXISTS lens_make VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255)
|
||||
`;
|
||||
|
||||
// Wrapper for most queries for JIT table creation/migration running
|
||||
const safelyQueryPhotos = async <T>(
|
||||
callback: () => Promise<T>,
|
||||
@ -89,19 +75,10 @@ const safelyQueryPhotos = async <T>(
|
||||
try {
|
||||
result = await callback();
|
||||
} catch (e: any) {
|
||||
if (MIGRATION_FIELDS_01.some(field => new RegExp(
|
||||
`column "${field}" of relation "photos" does not exist`,
|
||||
'i',
|
||||
).test(e.message))) {
|
||||
console.log('Running migration 01 ...');
|
||||
await runMigration01();
|
||||
result = await callback();
|
||||
} else if (MIGRATION_FIELDS_02.some(field => new RegExp(
|
||||
`column "${field}" of relation "photos" does not exist`,
|
||||
'i',
|
||||
).test(e.message))) {
|
||||
console.log('Running migration 02 ...');
|
||||
await runMigration02();
|
||||
const migration = migrationForError(e);
|
||||
if (migration) {
|
||||
console.log(`Running Migration ${migration.label} ...`);
|
||||
await migration.run();
|
||||
result = await callback();
|
||||
} else if (/relation "photos" does not exist/i.test(e.message)) {
|
||||
// If the table does not exist, create it
|
||||
@ -163,6 +140,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
|
||||
latitude,
|
||||
longitude,
|
||||
film_simulation,
|
||||
fujifilm_recipe,
|
||||
priority_order,
|
||||
hidden,
|
||||
taken_at,
|
||||
@ -192,6 +170,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
|
||||
${photo.latitude},
|
||||
${photo.longitude},
|
||||
${photo.filmSimulation},
|
||||
${JSON.stringify(photo.fujifilmRecipe)},
|
||||
${photo.priorityOrder},
|
||||
${photo.hidden},
|
||||
${photo.takenAt},
|
||||
@ -224,6 +203,7 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
|
||||
latitude=${photo.latitude},
|
||||
longitude=${photo.longitude},
|
||||
film_simulation=${photo.filmSimulation},
|
||||
fujifilm_recipe=${JSON.stringify(photo.fujifilmRecipe)},
|
||||
priority_order=${photo.priorityOrder || null},
|
||||
hidden=${photo.hidden},
|
||||
taken_at=${photo.takenAt},
|
||||
@ -467,3 +447,39 @@ export const getPhoto = async (
|
||||
.then(({ rows }) => rows.map(parsePhotoFromDb))
|
||||
.then(photos => photos.length > 0 ? photos[0] : undefined);
|
||||
}, 'getPhoto');
|
||||
|
||||
// Outdated queries
|
||||
|
||||
const outdatedWhereClause =
|
||||
// eslint-disable-next-line quotes
|
||||
`WHERE updated_at < $1 OR (updated_at < $2 AND make = $3)`;
|
||||
|
||||
const outdatedValues = [
|
||||
UPDATED_BEFORE_01.toISOString(),
|
||||
UPDATED_BEFORE_02.toISOString(),
|
||||
MAKE_FUJIFILM,
|
||||
];
|
||||
|
||||
export const getOutdatedPhotos = () => safelyQueryPhotos(
|
||||
() => query(`
|
||||
SELECT * FROM photos
|
||||
${outdatedWhereClause}
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1000
|
||||
`,
|
||||
outdatedValues,
|
||||
)
|
||||
.then(({ rows }) => rows.map(parsePhotoFromDb)),
|
||||
'getOutdatedPhotos',
|
||||
);
|
||||
|
||||
export const getOutdatedPhotosCount = () => safelyQueryPhotos(
|
||||
() => query(`
|
||||
SELECT COUNT(*) FROM photos
|
||||
${outdatedWhereClause}
|
||||
`,
|
||||
outdatedValues,
|
||||
)
|
||||
.then(({ rows }) => parseInt(rows[0].count, 10)),
|
||||
'getOutdatedPhotosCount',
|
||||
);
|
||||
|
||||
@ -101,10 +101,7 @@ export default function PhotoForm({
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
const fields = convertFormKeysToLabels(changedKeys);
|
||||
toastSuccess(
|
||||
`Updated EXIF fields: ${fields.join(', ')}`,
|
||||
8000,
|
||||
);
|
||||
toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000);
|
||||
} else {
|
||||
toastWarning('No new EXIF data found');
|
||||
}
|
||||
|
||||
@ -18,11 +18,12 @@ import { convertStringToArray } from '@/utility/string';
|
||||
import { generateNanoid } from '@/utility/nanoid';
|
||||
import {
|
||||
FILM_SIMULATION_FORM_INPUT_OPTIONS,
|
||||
MAKE_FUJIFILM,
|
||||
} from '@/platforms/fujifilm';
|
||||
} from '@/platforms/fujifilm/simulation';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { GEO_PRIVACY_ENABLED } from '@/app/config';
|
||||
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
||||
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
|
||||
type VirtualFields = 'favorite';
|
||||
|
||||
@ -107,6 +108,12 @@ const FORM_METADATA = (
|
||||
selectOptionsDefaultLabel: 'Unknown',
|
||||
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
|
||||
},
|
||||
fujifilmRecipe: {
|
||||
type: 'textarea',
|
||||
label: 'fujifilm recipe',
|
||||
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
|
||||
readOnly: true,
|
||||
},
|
||||
focalLength: { label: 'focal length' },
|
||||
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
|
||||
lensMake: { label: 'lens make' },
|
||||
@ -181,6 +188,8 @@ export const convertPhotoToFormData = (
|
||||
return value?.toISOString ? value.toISOString() : value;
|
||||
case 'hidden':
|
||||
return value ? 'true' : 'false';
|
||||
case 'fujifilmRecipe':
|
||||
return JSON.stringify(value);
|
||||
default:
|
||||
return value !== undefined && value !== null
|
||||
? value.toString()
|
||||
@ -200,6 +209,7 @@ export const convertPhotoToFormData = (
|
||||
export const convertExifToFormData = (
|
||||
data: ExifData,
|
||||
filmSimulation?: FilmSimulation,
|
||||
fujifilmRecipe?: FujifilmRecipe,
|
||||
): Omit<
|
||||
Record<keyof PhotoExif, string | undefined>,
|
||||
'takenAt' | 'takenAtNaive'
|
||||
@ -223,6 +233,7 @@ export const convertExifToFormData = (
|
||||
longitude:
|
||||
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
|
||||
filmSimulation,
|
||||
fujifilmRecipe: JSON.stringify(fujifilmRecipe),
|
||||
...data.tags?.DateTimeOriginal && {
|
||||
takenAt: convertTimestampWithOffsetToPostgresString(
|
||||
data.tags.DateTimeOriginal,
|
||||
@ -267,7 +278,10 @@ export const convertFormDataToPhotoDbInsert = (
|
||||
});
|
||||
|
||||
return {
|
||||
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
||||
...(photoForm as PhotoFormData & {
|
||||
filmSimulation?: FilmSimulation
|
||||
fujifilmRecipe?: FujifilmRecipe
|
||||
}),
|
||||
...!photoForm.id && { id: generateNanoid() },
|
||||
// Delete array field when empty
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
|
||||
@ -20,8 +20,7 @@ import { parameterize } from '@/utility/string';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { isBefore } from 'date-fns';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const OUTDATED_THRESHOLD = new Date('2024-06-16');
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
|
||||
// INFINITE SCROLL: FEED
|
||||
export const INFINITE_SCROLL_FEED_INITIAL =
|
||||
@ -66,6 +65,7 @@ export interface PhotoExif {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
filmSimulation?: FilmSimulation
|
||||
fujifilmRecipe?: string
|
||||
takenAt?: string
|
||||
takenAtNaive?: string
|
||||
}
|
||||
@ -88,7 +88,8 @@ export interface PhotoDbInsert extends PhotoExif {
|
||||
}
|
||||
|
||||
// Raw db response
|
||||
export interface PhotoDb extends Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
|
||||
export interface PhotoDb extends
|
||||
Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
|
||||
updatedAt: Date
|
||||
createdAt: Date
|
||||
takenAt: Date
|
||||
@ -96,7 +97,7 @@ export interface PhotoDb extends Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
|
||||
}
|
||||
|
||||
// Parsed db response
|
||||
export interface Photo extends PhotoDb {
|
||||
export interface Photo extends Omit<PhotoDb, 'fujifilmRecipe'> {
|
||||
focalLengthFormatted?: string
|
||||
focalLengthIn35MmFormatFormatted?: string
|
||||
fNumberFormatted?: string
|
||||
@ -104,6 +105,7 @@ export interface Photo extends PhotoDb {
|
||||
exposureTimeFormatted?: string
|
||||
exposureCompensationFormatted?: string
|
||||
takenAtNaiveFormatted: string
|
||||
fujifilmRecipe?: FujifilmRecipe
|
||||
}
|
||||
|
||||
export interface PhotoSetCategory {
|
||||
@ -139,6 +141,9 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
|
||||
formatExposureTime(photoDb.exposureTime),
|
||||
exposureCompensationFormatted:
|
||||
formatExposureCompensation(photoDb.exposureCompensation),
|
||||
fujifilmRecipe: photoDb.fujifilmRecipe
|
||||
? JSON.parse(photoDb.fujifilmRecipe)
|
||||
: undefined,
|
||||
takenAtNaiveFormatted:
|
||||
formatDateFromPostgresString(photoDb.takenAtNaive),
|
||||
};
|
||||
@ -159,6 +164,7 @@ export const convertPhotoToPhotoDbInsert = (
|
||||
): PhotoDbInsert => ({
|
||||
...photo,
|
||||
takenAt: photo.takenAt.toISOString(),
|
||||
fujifilmRecipe: JSON.stringify(photo.fujifilmRecipe),
|
||||
});
|
||||
|
||||
export const photoStatsAsString = (photo: Photo) => [
|
||||
|
||||
13
src/photo/outdated.ts
Normal file
13
src/photo/outdated.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||
import { Photo } from '.';
|
||||
|
||||
export const UPDATED_BEFORE_01 = new Date('2024-06-16');
|
||||
// UTC 2025-02-24 05:30:00
|
||||
export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0));
|
||||
|
||||
export const isPhotoOutdated = (photo: Photo) => {
|
||||
return photo.updatedAt < UPDATED_BEFORE_01 || (
|
||||
photo.updatedAt < UPDATED_BEFORE_02 &&
|
||||
photo.make === MAKE_FUJIFILM
|
||||
);
|
||||
};
|
||||
@ -5,8 +5,7 @@ import {
|
||||
import { convertExifToFormData } from '@/photo/form';
|
||||
import {
|
||||
getFujifilmSimulationFromMakerNote,
|
||||
isExifForFujifilm,
|
||||
} from '@/platforms/fujifilm';
|
||||
} from '@/platforms/fujifilm/simulation';
|
||||
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
|
||||
import { PhotoFormData } from './form';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
@ -15,7 +14,11 @@ import {
|
||||
GEO_PRIVACY_ENABLED,
|
||||
PRESERVE_ORIGINAL_UPLOADS,
|
||||
} from '@/app/config';
|
||||
|
||||
import { isExifForFujifilm } from '@/platforms/fujifilm';
|
||||
import {
|
||||
FujifilmRecipe,
|
||||
getFujifilmRecipeFromMakerNote,
|
||||
} from '@/platforms/fujifilm/recipe';
|
||||
const IMAGE_WIDTH_RESIZE = 200;
|
||||
const IMAGE_WIDTH_BLUR = 200;
|
||||
|
||||
@ -48,6 +51,7 @@ export const extractImageDataFromBlobPath = async (
|
||||
|
||||
let exifData: ExifData | undefined;
|
||||
let filmSimulation: FilmSimulation | undefined;
|
||||
let recipe: FujifilmRecipe | undefined;
|
||||
let blurData: string | undefined;
|
||||
let imageResizedBase64: string | undefined;
|
||||
let shouldStripGpsData = false;
|
||||
@ -78,6 +82,7 @@ export const extractImageDataFromBlobPath = async (
|
||||
const makerNote = exifDataBinary.tags?.MakerNote;
|
||||
if (Buffer.isBuffer(makerNote)) {
|
||||
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
|
||||
recipe = getFujifilmRecipeFromMakerNote(makerNote);
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,7 +116,7 @@ export const extractImageDataFromBlobPath = async (
|
||||
url,
|
||||
},
|
||||
...generateBlurData && { blurData },
|
||||
...convertExifToFormData(exifData, filmSimulation),
|
||||
...convertExifToFormData(exifData, filmSimulation, recipe),
|
||||
},
|
||||
},
|
||||
imageResizedBase64,
|
||||
|
||||
87
src/photo/useRecipeState.ts
Normal file
87
src/photo/useRecipeState.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
getPathComponents,
|
||||
pathForPhoto,
|
||||
SEARCH_PARAM_SHOW_RECIPE,
|
||||
} from '@/app/paths';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SEARCH_PARAM_SHOW } from '@/app/paths';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { RefObject, useCallback, useEffect, useState } from 'react';
|
||||
import { isElementEntirelyInViewport } from '@/utility/dom';
|
||||
import useClickInsideOutside from '@/utility/useClickInsideOutside';
|
||||
|
||||
export default function useRecipeState({
|
||||
ref,
|
||||
refTrigger,
|
||||
}: {
|
||||
ref?: RefObject<HTMLElement | null>,
|
||||
refTrigger?: RefObject<HTMLElement | null>,
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
|
||||
const {
|
||||
photoId,
|
||||
...pathComponents
|
||||
} = getPathComponents(pathname);
|
||||
|
||||
const showRecipeInitially =
|
||||
params.get(SEARCH_PARAM_SHOW) === SEARCH_PARAM_SHOW_RECIPE;
|
||||
|
||||
const [shouldShowRecipe, setShouldShowRecipe] = useState(showRecipeInitially);
|
||||
|
||||
const setVisibility = useCallback((shouldShow: boolean) => {
|
||||
if (shouldShow) {
|
||||
setShouldShowRecipe(true);
|
||||
// Only add query param for photo details
|
||||
if (photoId) {
|
||||
window.history.pushState(
|
||||
null,
|
||||
'',
|
||||
pathForPhoto({
|
||||
photo: photoId,
|
||||
...pathComponents,
|
||||
showRecipe: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setShouldShowRecipe(false);
|
||||
// Only remove query param for photo details
|
||||
if (photoId) {
|
||||
window.history.pushState(
|
||||
null,
|
||||
'',
|
||||
pathForPhoto({
|
||||
photo: photoId,
|
||||
...pathComponents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [pathComponents, photoId]);
|
||||
|
||||
const showRecipe = useCallback(() => setVisibility(true), [setVisibility]);
|
||||
const hideRecipe = useCallback(() => setVisibility(false), [setVisibility]);
|
||||
const toggleRecipe = useCallback(() =>
|
||||
setVisibility(!shouldShowRecipe),
|
||||
[setVisibility, shouldShowRecipe]);
|
||||
|
||||
useClickInsideOutside({
|
||||
htmlElements: [ref, refTrigger],
|
||||
onClickOutside: hideRecipe,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
|
||||
ref?.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [ref, shouldShowRecipe]);
|
||||
|
||||
return {
|
||||
shouldShowRecipe,
|
||||
showRecipe,
|
||||
hideRecipe,
|
||||
toggleRecipe,
|
||||
};
|
||||
}
|
||||
81
src/platforms/fujifilm/index.ts
Normal file
81
src/platforms/fujifilm/index.ts
Normal file
@ -0,0 +1,81 @@
|
||||
// MakerNote tag IDs and values referenced from:
|
||||
// - github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm
|
||||
// - exiftool.org/TagNames/FujiFilm.html
|
||||
|
||||
import type { ExifData } from 'ts-exif-parser';
|
||||
|
||||
export const MAKE_FUJIFILM = 'FUJIFILM';
|
||||
|
||||
// Makernote Offsets
|
||||
const BYTE_OFFSET_TAG_COUNT = 12;
|
||||
const BYTE_OFFSET_FIRST_TAG = 14;
|
||||
|
||||
// Tag Offsets
|
||||
const BYTE_OFFSET_TAG_TYPE = 2;
|
||||
const BYTE_OFFSET_TAG_SIZE = 4;
|
||||
const BYTE_OFFSET_TAG_VALUE = 8;
|
||||
|
||||
// Tag Sizes
|
||||
const BYTES_PER_TAG = 12;
|
||||
const BYTES_PER_TAG_VALUE = 4;
|
||||
|
||||
export const isExifForFujifilm = (data: ExifData) =>
|
||||
data.tags?.Make === MAKE_FUJIFILM;
|
||||
|
||||
export const parseFujifilmMakerNote = (
|
||||
bytes: Buffer,
|
||||
sendTagNumbers: (tagId: number, numbers: number[]) => void,
|
||||
) => {
|
||||
const tagCount = bytes.readUint16LE(BYTE_OFFSET_TAG_COUNT);
|
||||
|
||||
for (let i = 0; i < tagCount; i++) {
|
||||
const index = BYTE_OFFSET_FIRST_TAG + i * BYTES_PER_TAG;
|
||||
|
||||
if (index + BYTES_PER_TAG < bytes.length) {
|
||||
const tagId = bytes.readUInt16LE(index);
|
||||
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
|
||||
const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE);
|
||||
|
||||
const sendNumbersForDataType = (
|
||||
parseNumberAtOffset: (offset: number) => number,
|
||||
sizeInBytes: number,
|
||||
) => {
|
||||
let values: number[] = [];
|
||||
if (tagValueSize * sizeInBytes <= BYTES_PER_TAG_VALUE) {
|
||||
// Retrieve values if they fit in tag block
|
||||
values = Array.from({ length: tagValueSize }, (_, i) =>
|
||||
parseNumberAtOffset(
|
||||
index + BYTE_OFFSET_TAG_VALUE + i * sizeInBytes,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Retrieve outside values if they don't fit in tag block
|
||||
const offset = bytes.readUint16LE(index + BYTE_OFFSET_TAG_VALUE);
|
||||
for (let i = 0; i < tagValueSize; i++) {
|
||||
values.push(parseNumberAtOffset(offset + i * sizeInBytes));
|
||||
}
|
||||
}
|
||||
sendTagNumbers(tagId, values);
|
||||
};
|
||||
|
||||
switch (tagType) {
|
||||
// Int8 (UInt8 read as Int8 according to spec)
|
||||
case 1:
|
||||
sendNumbersForDataType(offset => bytes.readInt8(offset), 1);
|
||||
break;
|
||||
// UInt16
|
||||
case 3:
|
||||
sendNumbersForDataType(offset => bytes.readUInt16LE(offset), 2);
|
||||
break;
|
||||
// UInt32
|
||||
case 4:
|
||||
sendNumbersForDataType(offset => bytes.readUInt32LE(offset), 4);
|
||||
break;
|
||||
// Int32
|
||||
case 9:
|
||||
sendNumbersForDataType(offset => bytes.readInt32LE(offset), 4);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
257
src/platforms/fujifilm/recipe.ts
Normal file
257
src/platforms/fujifilm/recipe.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { parseFujifilmMakerNote } from '.';
|
||||
|
||||
const TAG_ID_DYNAMIC_RANGE = 0x1400;
|
||||
const TAG_ID_DYNAMIC_RANGE_SETTING = 0x1402;
|
||||
const TAG_ID_DEVELOPMENT_DYNAMIC_RANGE = 0x1403;
|
||||
const TAG_ID_WHITE_BALANCE = 0x1002;
|
||||
const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x100a;
|
||||
const TAG_ID_NOISE_REDUCTION = 0x100e;
|
||||
const TAG_ID_NOISE_REDUCTION_BASIC = 0x100b;
|
||||
const TAG_ID_HIGHLIGHT = 0x1041;
|
||||
const TAG_ID_SHADOW = 0x1040;
|
||||
const TAG_ID_SATURATION = 0x1003;
|
||||
const TAG_ID_SHARPNESS = 0x1001;
|
||||
const TAG_ID_CLARITY = 0x100f;
|
||||
const TAG_ID_COLOR_CHROME_EFFECT = 0x1048;
|
||||
const TAG_ID_COLOR_CHROME_FX_BLUE = 0x104e;
|
||||
const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047;
|
||||
const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c;
|
||||
const TAG_ID_BW_ADJUSTMENT = 0x1049;
|
||||
const TAG_ID_BW_MAGENTA_GREEN = 0x104b;
|
||||
|
||||
type WeakStrong = 'off' | 'weak' | 'strong';
|
||||
|
||||
export type FujifilmRecipe = {
|
||||
dynamicRange: {
|
||||
range: 'standard' | 'wide'
|
||||
// eslint-disable-next-line max-len
|
||||
setting: 'auto' | 'manual' | 'standard' | 'wide-1' | 'wide-2' | 'film-simulation'
|
||||
development: number
|
||||
}
|
||||
whiteBalance: {
|
||||
type: string
|
||||
red: number
|
||||
blue: number
|
||||
}
|
||||
highISONoiseReduction?: number
|
||||
noiseReductionBasic?: string
|
||||
highlight?: number
|
||||
shadow?: number
|
||||
color?: number
|
||||
sharpness?: number
|
||||
clarity?: number
|
||||
colorChromeEffect?: WeakStrong
|
||||
colorChromeFXBlue?: WeakStrong
|
||||
grainEffect: {
|
||||
roughness: WeakStrong
|
||||
size: 'off' | 'small' | 'large'
|
||||
}
|
||||
bwAdjustment?: number
|
||||
bwMagentaGreen?: number
|
||||
};
|
||||
|
||||
const DEFAULT_DYNAMIC_RANGE = {
|
||||
range: 'standard',
|
||||
setting: 'auto',
|
||||
development: 100,
|
||||
} as const;
|
||||
|
||||
const DEFAULT_WHITE_BALANCE = {
|
||||
type: 'auto',
|
||||
red: 0,
|
||||
blue: 0,
|
||||
} as const;
|
||||
|
||||
const DEFAULT_GRAIN_EFFECT = {
|
||||
roughness: 'off',
|
||||
size: 'off',
|
||||
} as const;
|
||||
|
||||
export const processDynamicRangeSettings = (
|
||||
value: number,
|
||||
): FujifilmRecipe['dynamicRange']['setting'] => {
|
||||
switch (value) {
|
||||
case 0x001: return 'manual';
|
||||
case 0x100: return 'standard';
|
||||
case 0x200: return 'wide-1';
|
||||
case 0x201: return 'wide-1';
|
||||
case 0x8000: return 'film-simulation';
|
||||
default: return 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
export const processTone = (value: number) =>
|
||||
value === 0 ? 0 : -(value / 16);
|
||||
|
||||
export const processSaturation = (value: number) => {
|
||||
switch (value) {
|
||||
case 0x4e0: return -4;
|
||||
case 0x4c0: return -3;
|
||||
case 0x400: return -2;
|
||||
case 0x180: return -1;
|
||||
case 0x80: return 1;
|
||||
case 0x100: return 2;
|
||||
case 0xc0: return 3;
|
||||
case 0xe0: return 4;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const processNoiseReductionLegacy = (value: number) => {
|
||||
switch (value) {
|
||||
case 0x40: return 'low';
|
||||
case 0x80: return 'normal';
|
||||
default: return 'n/a';
|
||||
}
|
||||
};
|
||||
|
||||
export const processNoiseReduction = (value: number) => {
|
||||
switch (value) {
|
||||
case 0x2e0: return -4;
|
||||
case 0x2c0: return -3;
|
||||
case 0x200: return -2;
|
||||
case 0x280: return -1;
|
||||
case 0x180: return 1;
|
||||
case 0x100: return 2;
|
||||
case 0x1c0: return 3;
|
||||
case 0x1e0: return 4;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const processSharpness = (value: number) => {
|
||||
switch (value) {
|
||||
case 0x0: return -4;
|
||||
case 0x1: return -3;
|
||||
case 0x2: return -2;
|
||||
case 0x82: return -1;
|
||||
case 0x84: return 1;
|
||||
case 0x4: return 2;
|
||||
case 0x5: return 3;
|
||||
case 0x6: return 4;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const processClarity = (value: number) => value / 1000;
|
||||
|
||||
export const processWeakStrong = (value: number): WeakStrong => {
|
||||
switch (value) {
|
||||
case 32: return 'weak';
|
||||
case 64: return 'strong';
|
||||
default: return 'off';
|
||||
}
|
||||
};
|
||||
|
||||
export const processGrainEffectSize = (
|
||||
value: number,
|
||||
): Required<FujifilmRecipe>['grainEffect']['size'] => {
|
||||
switch (value) {
|
||||
case 16: return 'small';
|
||||
case 32: return 'large';
|
||||
default: return 'off';
|
||||
}
|
||||
};
|
||||
|
||||
export const processWhiteBalanceType = (value: number) => {
|
||||
switch (value) {
|
||||
case 0x1: return 'auto-white-priority';
|
||||
case 0x2: return 'auto-ambiance-priority';
|
||||
case 0x100: return 'daylight';
|
||||
case 0x200: return 'cloudy';
|
||||
case 0x300: return 'daylight-fluorescent';
|
||||
case 0x301: return 'day-white-fluorescent';
|
||||
case 0x302: return 'white-fluorescent';
|
||||
case 0x303: return 'warm-white-fluorescent';
|
||||
case 0x304: return 'living-room-warm-white-fluorescent';
|
||||
case 0x400: return 'incandescent';
|
||||
case 0x500: return 'flash';
|
||||
case 0x600: return 'underwater';
|
||||
case 0xf00: return 'custom';
|
||||
case 0xf01: return 'custom-2';
|
||||
case 0xf02: return 'custom-3';
|
||||
case 0xf03: return 'custom-4';
|
||||
case 0xf04: return 'custom-5';
|
||||
case 0xff0: return 'kelvin';
|
||||
default: return 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
export const processWhiteBalanceComponent = (value: number) => value / 20;
|
||||
|
||||
export const getFujifilmRecipeFromMakerNote = (
|
||||
bytes: Buffer,
|
||||
): FujifilmRecipe => {
|
||||
const recipe: FujifilmRecipe = {
|
||||
dynamicRange: DEFAULT_DYNAMIC_RANGE,
|
||||
whiteBalance: DEFAULT_WHITE_BALANCE,
|
||||
grainEffect: DEFAULT_GRAIN_EFFECT,
|
||||
};
|
||||
|
||||
parseFujifilmMakerNote(
|
||||
bytes,
|
||||
(tag, numbers) => {
|
||||
switch (tag) {
|
||||
case TAG_ID_DYNAMIC_RANGE:
|
||||
recipe.dynamicRange.range = numbers[0] === 3
|
||||
? 'wide'
|
||||
: 'standard';
|
||||
break;
|
||||
case TAG_ID_DYNAMIC_RANGE_SETTING:
|
||||
recipe.dynamicRange.setting = processDynamicRangeSettings(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE:
|
||||
recipe.dynamicRange.development = numbers[0];
|
||||
break;
|
||||
case TAG_ID_WHITE_BALANCE:
|
||||
recipe.whiteBalance.type = processWhiteBalanceType(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_WHITE_BALANCE_FINE_TUNE:
|
||||
recipe.whiteBalance.red = processWhiteBalanceComponent(numbers[0]);
|
||||
recipe.whiteBalance.blue = processWhiteBalanceComponent(numbers[1]);
|
||||
break;
|
||||
case TAG_ID_NOISE_REDUCTION:
|
||||
recipe.highISONoiseReduction = processNoiseReduction(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_NOISE_REDUCTION_BASIC:
|
||||
recipe.noiseReductionBasic = processNoiseReductionLegacy(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_HIGHLIGHT:
|
||||
recipe.highlight = processTone(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_SHADOW:
|
||||
recipe.shadow = processTone(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_SATURATION:
|
||||
recipe.color = processSaturation(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_SHARPNESS:
|
||||
recipe.sharpness = processSharpness(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_CLARITY:
|
||||
recipe.clarity = processClarity(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_COLOR_CHROME_EFFECT:
|
||||
recipe.colorChromeEffect = processWeakStrong(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_COLOR_CHROME_FX_BLUE:
|
||||
recipe.colorChromeFXBlue = processWeakStrong(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_GRAIN_EFFECT_ROUGHNESS:
|
||||
recipe.grainEffect.roughness = processWeakStrong(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_GRAIN_EFFECT_SIZE:
|
||||
recipe.grainEffect.size = processGrainEffectSize(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_BW_ADJUSTMENT:
|
||||
recipe.bwAdjustment = numbers[0];
|
||||
break;
|
||||
case TAG_ID_BW_MAGENTA_GREEN:
|
||||
recipe.bwMagentaGreen = numbers[0];
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return recipe;
|
||||
};
|
||||
@ -1,15 +1,4 @@
|
||||
// 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';
|
||||
|
||||
export const MAKE_FUJIFILM = 'FUJIFILM';
|
||||
|
||||
const BYTE_INDEX_TAG_COUNT = 12;
|
||||
const BYTE_INDEX_FIRST_TAG = 14;
|
||||
const BYTES_PER_TAG = 12;
|
||||
const BYTE_OFFSET_TAG_TYPE = 2;
|
||||
const BYTE_OFFSET_TAG_VALUE = 8;
|
||||
import { parseFujifilmMakerNote } from '.';
|
||||
|
||||
const TAG_ID_SATURATION = 0x1003;
|
||||
const TAG_ID_FILM_MODE = 0x1401;
|
||||
@ -46,9 +35,6 @@ export type FujifilmSimulation =
|
||||
FujifilmSimulationFromSaturation |
|
||||
FujifilmMode;
|
||||
|
||||
export const isExifForFujifilm = (data: ExifData) =>
|
||||
data.tags?.Make === MAKE_FUJIFILM;
|
||||
|
||||
const getFujifilmSimulationFromSaturation = (
|
||||
value?: number,
|
||||
): FujifilmSimulationFromSaturation | undefined => {
|
||||
@ -231,36 +217,6 @@ export const FILM_SIMULATION_FORM_INPUT_OPTIONS = Object
|
||||
export const labelForFilmSimulation = (simulation: FujifilmSimulation) =>
|
||||
FILM_SIMULATION_LABELS[simulation];
|
||||
|
||||
const parseFujifilmMakerNote = (
|
||||
bytes: Buffer,
|
||||
valueForTagUInt: (tagId: number, value: number) => void,
|
||||
) => {
|
||||
const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT);
|
||||
for (let i = 0; i < tagCount; i++) {
|
||||
const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG;
|
||||
if (index + BYTES_PER_TAG < bytes.length) {
|
||||
const tagId = bytes.readUInt16LE(index);
|
||||
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
|
||||
switch (tagType) {
|
||||
// UInt16
|
||||
case 3:
|
||||
valueForTagUInt(
|
||||
tagId,
|
||||
bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE),
|
||||
);
|
||||
break;
|
||||
// UInt32
|
||||
case 4:
|
||||
valueForTagUInt(
|
||||
tagId,
|
||||
bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getFujifilmSimulationFromMakerNote = (
|
||||
bytes: Buffer,
|
||||
): FujifilmSimulation | undefined => {
|
||||
@ -269,13 +225,15 @@ export const getFujifilmSimulationFromMakerNote = (
|
||||
|
||||
parseFujifilmMakerNote(
|
||||
bytes,
|
||||
(tag, value) => {
|
||||
(tag, numbers) => {
|
||||
switch (tag) {
|
||||
case TAG_ID_SATURATION:
|
||||
filmModeFromSaturation = getFujifilmSimulationFromSaturation(value);
|
||||
filmModeFromSaturation =
|
||||
getFujifilmSimulationFromSaturation(numbers[0]);
|
||||
break;
|
||||
case TAG_ID_FILM_MODE:
|
||||
filmMode = getFujifilmMode(value);
|
||||
filmMode =
|
||||
getFujifilmMode(numbers[0]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
@ -1,10 +1,12 @@
|
||||
import { labelForFilmSimulation } from '@/platforms/fujifilm';
|
||||
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon';
|
||||
import { pathForFilmSimulation } from '@/app/paths';
|
||||
import { FilmSimulation } from '.';
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
import clsx from 'clsx/lite';
|
||||
|
||||
export default function PhotoFilmSimulation({
|
||||
simulation,
|
||||
@ -16,6 +18,8 @@ export default function PhotoFilmSimulation({
|
||||
}: {
|
||||
simulation: FilmSimulation
|
||||
countOnHover?: number
|
||||
recipe?: FujifilmRecipe
|
||||
className?: string
|
||||
} & EntityLinkExternalProps) {
|
||||
const { small, medium, large } = labelForFilmSimulation(simulation);
|
||||
|
||||
@ -24,7 +28,10 @@ export default function PhotoFilmSimulation({
|
||||
label={medium}
|
||||
labelSmall={small}
|
||||
href={pathForFilmSimulation(simulation)}
|
||||
icon={<PhotoFilmSimulationIcon simulation={simulation} />}
|
||||
icon={<PhotoFilmSimulationIcon
|
||||
simulation={simulation}
|
||||
className={clsx(contrast === 'frosted' && 'text-black')}
|
||||
/>}
|
||||
title={`Film Simulation: ${large}`}
|
||||
type={type}
|
||||
badged={badged}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-len */
|
||||
import { labelForFilmSimulation } from '@/platforms/fujifilm';
|
||||
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import { CSSProperties } from 'react';
|
||||
import { FilmSimulation } from '.';
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
FujifilmSimulation,
|
||||
labelForFilmSimulation,
|
||||
} from '@/platforms/fujifilm';
|
||||
} from '@/platforms/fujifilm/simulation';
|
||||
|
||||
export type FilmSimulation = FujifilmSimulation;
|
||||
|
||||
|
||||
@ -46,6 +46,8 @@ export interface AppStateContext {
|
||||
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugInsights?: boolean
|
||||
setShouldDebugInsights?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugRecipeOverlays?: boolean
|
||||
setShouldDebugRecipeOverlays?: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const AppStateContext = createContext<AppStateContext>({});
|
||||
|
||||
@ -65,6 +65,8 @@ export default function AppStateProvider({
|
||||
useState(false);
|
||||
const [shouldDebugInsights, setShouldDebugInsights] =
|
||||
useState(IS_DEVELOPMENT);
|
||||
const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] =
|
||||
useState(false);
|
||||
|
||||
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
|
||||
|
||||
@ -143,6 +145,8 @@ export default function AppStateProvider({
|
||||
setShouldShowBaselineGrid,
|
||||
shouldDebugInsights,
|
||||
setShouldDebugInsights,
|
||||
shouldDebugRecipeOverlays,
|
||||
setShouldDebugRecipeOverlays,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
20
src/utility/dom.ts
Normal file
20
src/utility/dom.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const isElementEntirelyInViewport = (
|
||||
element?: HTMLElement | null,
|
||||
) => {
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (
|
||||
window.innerHeight ||
|
||||
document.documentElement.clientHeight
|
||||
) &&
|
||||
rect.right <= (
|
||||
window.innerWidth || document.documentElement.clientWidth
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@ -4,7 +4,7 @@ const MOUSE_DOWN = 'mousedown';
|
||||
|
||||
interface Options {
|
||||
// HTML reference
|
||||
htmlElements: RefObject<HTMLElement | null>[],
|
||||
htmlElements: (RefObject<HTMLElement | null> | undefined)[],
|
||||
// Callbacks based on click target
|
||||
onClick?: (event?: MouseEvent) => void,
|
||||
onClickInside?: (event?: MouseEvent) => void,
|
||||
@ -24,7 +24,7 @@ const useClickInsideOutside = ({
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const htmlElementsContainTarget = htmlElements
|
||||
.some(element => element.current?.contains(target));
|
||||
.some(element => element?.current?.contains(target));
|
||||
|
||||
// On click
|
||||
onClick?.(event);
|
||||
|
||||
12
tailwind.css
12
tailwind.css
@ -86,14 +86,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Text Primitives */
|
||||
@utility text-dark {
|
||||
@apply text-gray-900
|
||||
}
|
||||
@utility text-light {
|
||||
@apply text-gray-100
|
||||
}
|
||||
|
||||
/* Text */
|
||||
@utility text-main {
|
||||
@apply
|
||||
text-gray-900 dark:text-gray-100
|
||||
text-dark dark:text-light
|
||||
}
|
||||
@utility text-invert {
|
||||
@apply
|
||||
text-white dark:text-black
|
||||
text-light dark:text-dark
|
||||
}
|
||||
@utility text-medium {
|
||||
@apply
|
||||
|
||||
Loading…
Reference in New Issue
Block a user