Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-03-30 11:14:15 -05:00
commit 351b68f3e5
50 changed files with 739 additions and 345 deletions

View File

@ -1,14 +1,30 @@
import { Camera, formatCameraText } from '@/camera';
const APPLE : Camera = { make: 'Apple', model: 'iPhone 11 Pro' };
const APPLE_01 : Camera = { make: 'Apple', model: 'iPhone 11' };
const APPLE_02 : Camera = { make: 'Apple', model: 'iPhone 15 Pro Max' };
const FUJIFILM : Camera = { make: 'Fujifilm', model: 'X-T5' };
const CANON : Camera = { make: 'Canon', model: 'Canon EOS 800D' };
const NIKON : Camera = {
make: 'Nikon Corporation',
model: 'Nikon D7000',
};
describe('Camera', () => {
it('labels correctly', () => {
const apple: Camera = { make: 'Apple', model: 'iPhone 11 Pro' };
expect(formatCameraText(apple, true)).toBe('Apple iPhone 11 Pro');
expect(formatCameraText(apple, false)).toBe('iPhone 11 Pro');
const fujifilm: Camera = { make: 'Fujifilm', model: 'X-T5' };
expect(formatCameraText(fujifilm)).toBe('Fujifilm X-T5');
const canon: Camera = { make: 'Canon', model: 'Canon EOS 800D' };
expect(formatCameraText(canon)).toBe('Canon EOS 800D');
it('labels full text correctly', () => {
expect(formatCameraText(APPLE)).toBe('iPhone 11 Pro');
expect(formatCameraText(APPLE, 'always')).toBe('Apple iPhone 11 Pro');
expect(formatCameraText(APPLE, 'if-not-apple')).toBe('iPhone 11 Pro');
expect(formatCameraText(APPLE, 'never')).toBe('iPhone 11 Pro');
expect(formatCameraText(FUJIFILM)).toBe('Fujifilm X-T5');
expect(formatCameraText(CANON)).toBe('Canon EOS 800D');
expect(formatCameraText(NIKON)).toBe('Nikon D7000');
});
it('labels models correctly', () => {
expect(formatCameraText(APPLE, 'never')).toBe('iPhone 11 Pro');
expect(formatCameraText(APPLE, 'never', true)).toBe('11 Pro');
expect(formatCameraText(APPLE_01, 'never', true)).toBe('iPhone 11');
expect(formatCameraText(APPLE_02, 'never', true)).toBe('15 Pro Max');
});
});

View File

@ -18,12 +18,12 @@ import {
isPathTagPhotoShare,
isPathTagShare,
} from '@/site/paths';
import { getCameraFromKey } from '@/camera';
const PHOTO_ID = 'UsKSGcbt';
const TAG = 'tag-name';
const CAMERA = 'fujifilm-x-t1';
const CAMERA_OBJECT = getCameraFromKey(CAMERA);
const CAMERA_MAKE = 'fujifilm';
const CAMERA_MODEL = 'x-t1';
const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL };
const FILM_SIMULATION = 'acros';
const SHARE = 'share';
@ -39,7 +39,7 @@ const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`;
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`;
const PATH_CAMERA = `/shot-on/${CAMERA}`;
const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`;
const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`;
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`;
const PATH_CAMERA_PHOTO_SHARE = `${PATH_CAMERA_PHOTO}/${SHARE}`;

View File

@ -42,7 +42,7 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.6",
"next": "14.2.0-canary.39",
"next": "14.2.0-canary.49",
"next-auth": "5.0.0-beta.15",
"next-themes": "^0.3.0",
"openai": "^4.29.2",

108
pnpm-lock.yaml generated
View File

@ -52,7 +52,7 @@ dependencies:
version: 1.0.1
'@vercel/analytics':
specifier: ^1.2.2
version: 1.2.2(next@14.2.0-canary.39)(react@18.2.0)
version: 1.2.2(next@14.2.0-canary.49)(react@18.2.0)
'@vercel/blob':
specifier: ^0.22.1
version: 0.22.1
@ -64,7 +64,7 @@ dependencies:
version: 0.7.2
'@vercel/speed-insights':
specifier: ^1.0.10
version: 1.0.10(next@14.2.0-canary.39)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21)
version: 1.0.10(next@14.2.0-canary.49)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21)
ai:
specifier: ^3.0.13
version: 3.0.13(react@18.2.0)(solid-js@1.8.15)(svelte@4.2.12)(vue@3.4.21)(zod@3.22.4)
@ -105,11 +105,11 @@ dependencies:
specifier: ^5.0.6
version: 5.0.6
next:
specifier: 14.2.0-canary.39
version: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
specifier: 14.2.0-canary.49
version: 14.2.0-canary.49(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next-auth:
specifier: 5.0.0-beta.15
version: 5.0.0-beta.15(next@14.2.0-canary.39)(react@18.2.0)
version: 5.0.0-beta.15(next@14.2.0-canary.49)(react@18.2.0)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.2.0)(react@18.2.0)
@ -1563,8 +1563,8 @@ packages:
- utf-8-validate
dev: false
/@next/env@14.2.0-canary.39:
resolution: {integrity: sha512-ROeqwq9mybhzfdzNDbz9/0e3fFB6gtC25NZNC/rhZzvgkTvUuYXUbJOJSvvtsoUjQolTCFOhZqKmopX+QgwYwQ==}
/@next/env@14.2.0-canary.49:
resolution: {integrity: sha512-rQaBRv0PRO3+4lx90zB9eBL0xk230G+6avgCyBL272hckH4XsGgXY6adtBBmZJF1QuDI+pS+DqppXSJvfexsdw==}
dev: false
/@next/eslint-plugin-next@14.1.4:
@ -1573,8 +1573,8 @@ packages:
glob: 10.3.10
dev: false
/@next/swc-darwin-arm64@14.2.0-canary.39:
resolution: {integrity: sha512-ImAEFQBac/jYFCQYAEOxLZlzZfoa0GnbmXlGruzyNXl7RG3gJ3OBXx6G/puySAdytp54tArmr+0h+xoEXbop2Q==}
/@next/swc-darwin-arm64@14.2.0-canary.49:
resolution: {integrity: sha512-tFFCgRJOk28rIiEGjz2bafqp3G5lV7hXyYjZ7d+gt/MjpLRrtTwu+lRBv/W1VFdTkPv8+k2hvXZNNTHO1n57Ow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -1582,8 +1582,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@14.2.0-canary.39:
resolution: {integrity: sha512-2q0F3L261vYPOrn7KXLX5SzfMe8yPRs0plnExpV2MwQjikt5OhlUdGwRRyEFT0DS0S0cyaKw00nENxBuDi7VyA==}
/@next/swc-darwin-x64@14.2.0-canary.49:
resolution: {integrity: sha512-NR4Meb67q8M2pNP5a8Tp3Zfar2Ao8ChHWcD3wEBgICcgJ4ZyCQCWXdM+VBsf8a3yuAoXmu1/cwOwWu1KXVC96A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -1591,8 +1591,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@14.2.0-canary.39:
resolution: {integrity: sha512-efraDAfAjQosfUdW8ZMjnrH3/mveQQxs055BdGfh+L0+hlTf05ECUH07tg3AKqihhnk+sgJUqigR5ZSsUYrqsw==}
/@next/swc-linux-arm64-gnu@14.2.0-canary.49:
resolution: {integrity: sha512-2bFQUNYnz6L7xOAzvejMj09iqmWwkjFyguGEfmNiFN0kPgJ4viSCKZvoiuG/MPh3VoDSz5N2qx1tehSCy7KbFA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1600,8 +1600,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@14.2.0-canary.39:
resolution: {integrity: sha512-Eb6+d3XkhwaEd69OoTOa4/scqQJtCUiZrmWjR0sVbW3QJ0wWu2o5gz8mInYsLeLwxN+HDy1aDuQSl3hp2PwBbQ==}
/@next/swc-linux-arm64-musl@14.2.0-canary.49:
resolution: {integrity: sha512-68PjCGC1JghA2tuznu+ExeSP+L6qpf6afblB4wFhDRniP+0hRrZB+1E3jJ3PmBgHtitJJMaplTFeKYQ8xbF8xw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1609,8 +1609,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@14.2.0-canary.39:
resolution: {integrity: sha512-HKkx1WCMsycDFOp76avVMCIGm/E0jw3yugfyIc/g1vRIh6fTOZ9iyLd1Uannu4MorTxGWS4g1ZRr1C5/9Ve8kg==}
/@next/swc-linux-x64-gnu@14.2.0-canary.49:
resolution: {integrity: sha512-eiDvo0bnYCI59UhaZrNV1k7wZPFHyQ2uJ7/MUH9yvZZcSKBxRDtNc3FmCAZjKiNx/SclMFRAtENLOlDzceRp5g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1618,8 +1618,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@14.2.0-canary.39:
resolution: {integrity: sha512-9W/UTFugvG0fYhNK5IqahiwldH3JSXmF2iCzQMbGMpyhjvOn1UirEZPwkMXz6tdSGXVHwxvvsuhZhZgBIt8csw==}
/@next/swc-linux-x64-musl@14.2.0-canary.49:
resolution: {integrity: sha512-XgwiLB/WkRjuhWoKZmlRsZl1b8C7dsYlRD3zqHPkrgWhERyyn3AoeRjIa/eHR6nxj7oTu2KHET1oSJoYobH70g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1627,8 +1627,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@14.2.0-canary.39:
resolution: {integrity: sha512-rtG2wYP3Sa67F2AqaX2qISefZbc/KN0fj5gPx3ReFIuK8/p6tR/L063xvyNmBZs22DZuc07EaFCQ9Px7EB0C2Q==}
/@next/swc-win32-arm64-msvc@14.2.0-canary.49:
resolution: {integrity: sha512-jqC5vhFOAewsGdWriuQqR2aalQ8dHJ1WkSl1psluTxpo5UgICBk+H0wQ93a0CEfD0Rj+8QjUFh+U1oYTqE4YIg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -1636,8 +1636,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@14.2.0-canary.39:
resolution: {integrity: sha512-Qh3vNCQQqghFuX4XhKuBhlleaRNIVFTspFMMKdQKFoATVVZh5n/PEeGEIgwjZjsjwfLPI82fkIvxhZkPujcAgg==}
/@next/swc-win32-ia32-msvc@14.2.0-canary.49:
resolution: {integrity: sha512-Zcfe1+FuFtMCtG0L7F9yh0yRhmLM2gGAUHW41FYN+Rtbi/JFS8qhs/M7pOPkqhEWWKqo3at64q7z8KQh+21VsQ==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -1645,8 +1645,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@14.2.0-canary.39:
resolution: {integrity: sha512-CPFzgcPYamtJpHtrHr55LsZ9g95l9vnm85OckaDQCK+359z4sgWk5Jp2ortPN/ZorDk+KjiixrE8x1Ix07Mk9g==}
/@next/swc-win32-x64-msvc@14.2.0-canary.49:
resolution: {integrity: sha512-yeCjnmqMmI9aNbRk3DTrKvCuImUWXU+Kl0XC9KFo8iLpOztpCQrMA+pf5s3GRqv1HRzbRoHsj+1VCPXzTmZrLA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -3147,7 +3147,7 @@ packages:
crypto-js: 4.2.0
dev: false
/@vercel/analytics@1.2.2(next@14.2.0-canary.39)(react@18.2.0):
/@vercel/analytics@1.2.2(next@14.2.0-canary.49)(react@18.2.0):
resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==}
peerDependencies:
next: '>= 13'
@ -3158,7 +3158,7 @@ packages:
react:
optional: true
dependencies:
next: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.49(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
server-only: 0.0.1
dev: false
@ -3190,7 +3190,7 @@ packages:
ws: 8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3)
dev: false
/@vercel/speed-insights@1.0.10(next@14.2.0-canary.39)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21):
/@vercel/speed-insights@1.0.10(next@14.2.0-canary.49)(react@18.2.0)(svelte@4.2.12)(vue@3.4.21):
resolution: {integrity: sha512-4uzdKB0RW6Ff2FkzshzjZ+RlJfLPxgm/00i0XXgxfMPhwnnsk92YgtqsxT9OcPLdJUyVU1DqFlSWWjIQMPkh0g==}
requiresBuild: true
peerDependencies:
@ -3214,7 +3214,7 @@ packages:
vue-router:
optional: true
dependencies:
next: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.49(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
svelte: 4.2.12
vue: 3.4.21(typescript@5.4.3)
@ -3227,7 +3227,7 @@ packages:
'@vue/shared': 3.4.21
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.0.2
source-map-js: 1.2.0
dev: false
/@vue/compiler-dom@3.4.21:
@ -3248,7 +3248,7 @@ packages:
estree-walker: 2.0.2
magic-string: 0.30.8
postcss: 8.4.38
source-map-js: 1.0.2
source-map-js: 1.2.0
dev: false
/@vue/compiler-ssr@3.4.21:
@ -3822,10 +3822,6 @@ packages:
engines: {node: '>=16'}
dev: false
/caniuse-lite@1.0.30001591:
resolution: {integrity: sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==}
dev: false
/caniuse-lite@1.0.30001600:
resolution: {integrity: sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==}
dev: false
@ -4037,7 +4033,7 @@ packages:
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
dependencies:
mdn-data: 2.0.30
source-map-js: 1.0.2
source-map-js: 1.2.0
dev: false
/css.escape@1.5.1:
@ -6381,7 +6377,7 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: false
/next-auth@5.0.0-beta.15(next@14.2.0-canary.39)(react@18.2.0):
/next-auth@5.0.0-beta.15(next@14.2.0-canary.49)(react@18.2.0):
resolution: {integrity: sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
@ -6398,7 +6394,7 @@ packages:
optional: true
dependencies:
'@auth/core': 0.28.0
next: 14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.49(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
dev: false
@ -6412,40 +6408,43 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/next@14.2.0-canary.39(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-sTAsUnf7ihBdvN0XwiPKe6kfqxUeEZJaHVOR5RIt2LJ2OnI1mVAp875hjKNxDeOxg2TjpxQCWiEEeKE8IV/tvw==}
/next@14.2.0-canary.49(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-sfryWP84xmqUOYAilbiojczTpTGCRTMch3w+EVppzPj0DS6gOWv9vPUHp/6uMWWZ+YX+n3GkYhwRK80Q+FG+kg==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.41.2
react: ^18.2.0
react-dom: ^18.2.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
sass:
optional: true
dependencies:
'@next/env': 14.2.0-canary.39
'@next/env': 14.2.0-canary.49
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001591
caniuse-lite: 1.0.30001600
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(@babel/core@7.23.9)(react@18.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.0-canary.39
'@next/swc-darwin-x64': 14.2.0-canary.39
'@next/swc-linux-arm64-gnu': 14.2.0-canary.39
'@next/swc-linux-arm64-musl': 14.2.0-canary.39
'@next/swc-linux-x64-gnu': 14.2.0-canary.39
'@next/swc-linux-x64-musl': 14.2.0-canary.39
'@next/swc-win32-arm64-msvc': 14.2.0-canary.39
'@next/swc-win32-ia32-msvc': 14.2.0-canary.39
'@next/swc-win32-x64-msvc': 14.2.0-canary.39
'@next/swc-darwin-arm64': 14.2.0-canary.49
'@next/swc-darwin-x64': 14.2.0-canary.49
'@next/swc-linux-arm64-gnu': 14.2.0-canary.49
'@next/swc-linux-arm64-musl': 14.2.0-canary.49
'@next/swc-linux-x64-gnu': 14.2.0-canary.49
'@next/swc-linux-x64-musl': 14.2.0-canary.49
'@next/swc-win32-arm64-msvc': 14.2.0-canary.49
'@next/swc-win32-ia32-msvc': 14.2.0-canary.49
'@next/swc-win32-x64-msvc': 14.2.0-canary.49
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@ -6843,7 +6842,7 @@ packages:
dependencies:
nanoid: 3.3.7
picocolors: 1.0.0
source-map-js: 1.0.2
source-map-js: 1.2.0
dev: false
/postcss@8.4.38:
@ -7341,11 +7340,6 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
dev: false
/source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}

View File

@ -0,0 +1,194 @@
'use client';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import SiteGrid from '@/components/SiteGrid';
import EntityLink from '@/components/primitives/EntityLink';
import LabeledIcon from '@/components/primitives/LabeledIcon';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { useAppState } from '@/state';
import { clsx } from 'clsx/lite';
import { useState } from 'react';
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
import { IoImageSharp } from 'react-icons/io5';
const DEBUG_LINES = new Array(22).fill(null);
export default function ComponentsPage() {
const {
shouldShowBaselineGrid,
setShouldShowBaselineGrid,
} = useAppState();
const [debugComponents, setDebugComponents] = useState(false);
return (
<SiteGrid
contentMain={<>
<h1 className="flex mb-6">
<div className="grow">
<span>Baseline Grid: </span>
<span className="text-dim">
<span className="md:hidden">13.5px / 19px</span>
<span className="hidden md:inline-block">14px / 20px</span>
</span>
</div>
<div className={clsx(
'flex gap-1',
'[&>*]:inline-flex [&>*]:gap-1 [&_input]:-translate-y-0.5',
)}>
<FieldSetWithStatus
id="grid"
label="Grid"
type="checkbox"
value={shouldShowBaselineGrid ? 'true' : 'false'}
onChange={e => setShouldShowBaselineGrid?.(e === 'true')}
/>
<FieldSetWithStatus
id="components"
label="Components"
type="checkbox"
value={debugComponents ? 'true' : 'false'}
onChange={e => setDebugComponents(e === 'true')}
/>
</div>
</h1>
<DivDebugBaselineGrid className="flex gap-8">
<div className="[&>*]:flex">
<div>
<LabeledIcon
icon={<FaCamera size={12} />}
debug={debugComponents}
>
Camera<br />Line two
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<IoImageSharp />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<EntityLink
icon={<FaHandSparkles />}
label="Image"
debug={debugComponents}
/>
</div>
<div>
<EntityLink
icon={<FaHandSparkles />}
label="Image"
badged
debug={debugComponents}
/>
</div>
<div>
<LabeledIcon
icon={<IoMdCamera size={12} />}
debug={debugComponents}
>
Canon Mark III
</LabeledIcon>
</div>
<div>
<EntityLink
icon={<PhotoFilmSimulationIcon simulation="astia" />}
label="Astia/Soft"
type="icon-last"
iconWide
badged
debug={debugComponents}
/>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<EntityLink
icon={<PhotoFilmSimulationIcon simulation="astia" />}
label="Astia/Soft"
type="icon-last"
badged
debug={debugComponents}
/>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
</div>
<div className={clsx(
debugComponents && '[&>*]:bg-gray-800',
'[&>*]:flex',
)}>
{DEBUG_LINES.map((_, i) =>
<div key={i}>
Line {(i + 1).toString().padStart(2, '0')}
</div>
)}
</div>
</DivDebugBaselineGrid>
</>}
/>
);
}

View File

@ -32,6 +32,7 @@ import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import MoreComponentsFromSearchParams from
'@/components/MoreComponentsFromSearchParams';
import { getPhotos } from '@/services/vercel-postgres';
import PhotoDate from '@/photo/PhotoDate';
const DEBUG_PHOTO_BLOBS = false;
@ -103,7 +104,7 @@ export default async function AdminPhotosPage({
'lg:w-[50%] uppercase',
'text-dim',
)}>
{photo.takenAtNaive}
<PhotoDate {...{ photo }} />
</div>
</div>
<div className={clsx(

View File

@ -11,19 +11,15 @@ import {
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached } from '@/photo/cache';
import { cameraFromPhoto } from '@/camera';
import { PhotoCameraProps, cameraFromPhoto } from '@/camera';
import { getPhotosCameraDataCached } from '@/camera/data';
import { ReactNode, cache } from 'react';
const getPhotoCachedCached =
cache((photoId: string) => getPhotoCached(photoId));
interface PhotoCameraProps {
params: { photoId: string, camera: string }
}
export async function generateMetadata({
params: { photoId, camera },
params: { photoId, make, model },
}: PhotoCameraProps): Promise<Metadata> {
const photo = await getPhotoCachedCached(photoId);
@ -35,7 +31,7 @@ export async function generateMetadata({
const url = absolutePathForPhoto(
photo,
undefined,
cameraFromPhoto(photo, camera),
cameraFromPhoto(photo, { make, model }),
);
return {
@ -57,14 +53,14 @@ export async function generateMetadata({
}
export default async function PhotoCameraPage({
params: { photoId, camera: cameraProp },
params: { photoId, make, model },
children,
}: PhotoCameraProps & { children: ReactNode }) {
const photo = await getPhotoCachedCached(photoId);
if (!photo) { redirect(PATH_ROOT); }
const camera = cameraFromPhoto(photo, cameraProp);
const camera = cameraFromPhoto(photo, { make, model });
const [
photos,

View File

@ -1,19 +1,17 @@
import { getPhotoCached } from '@/photo/cache';
import { cameraFromPhoto } from '@/camera';
import { PhotoCameraProps, cameraFromPhoto } from '@/camera';
import PhotoShareModal from '@/photo/PhotoShareModal';
import { PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation';
export default async function Share({
params: { photoId, camera: cameraProp },
}: {
params: { photoId: string, camera: string }
}) {
params: { photoId, make, model },
}: PhotoCameraProps) {
const photo = await getPhotoCached(photoId);
if (!photo) { return redirect(PATH_ROOT); }
const camera = cameraFromPhoto(photo, cameraProp);
const camera = cameraFromPhoto(photo, { make, model });
return <PhotoShareModal {...{ photo, camera }} />;
}

View File

@ -1,5 +1,5 @@
import { getPhotosCached } from '@/photo/cache';
import { getCameraFromKey } from '@/camera';
import { CameraProps, getCameraFromParams } from '@/camera';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
@ -13,9 +13,9 @@ export const runtime = 'edge';
export async function GET(
_: Request,
context: { params: { camera: string } },
context: CameraProps,
) {
const camera = getCameraFromKey(context.params.camera);
const camera = getCameraFromParams(context.params);
const [
photos,

View File

@ -1,5 +1,5 @@
import { getCameraFromKey } from '@/camera';
import { Metadata } from 'next/types';
import { CameraProps, getCameraFromParams } from '@/camera';
import { generateMetaForCamera } from '@/camera/meta';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { PaginationParams } from '@/site/pagination';
@ -9,14 +9,10 @@ import {
} from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview';
interface CameraProps {
params: { camera: string },
}
export async function generateMetadata({
params,
}: CameraProps): Promise<Metadata> {
const camera = getCameraFromKey(params.camera);
const camera = getCameraFromParams(params);
const [
photos,
@ -54,7 +50,7 @@ export default async function CameraPage({
params,
searchParams,
}: CameraProps & PaginationParams) {
const camera = getCameraFromKey(params.camera);
const camera = getCameraFromParams(params);
const {
photos,

View File

@ -1,4 +1,8 @@
import { cameraFromPhoto, getCameraFromKey } from '@/camera';
import {
CameraProps,
cameraFromPhoto,
getCameraFromParams,
} from '@/camera';
import CameraShareModal from '@/camera/CameraShareModal';
import { generateMetaForCamera } from '@/camera/meta';
import { Metadata } from 'next/types';
@ -10,14 +14,10 @@ import {
} from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview';
interface CameraProps {
params: { camera: string }
}
export async function generateMetadata({
params,
}: CameraProps): Promise<Metadata> {
const camera = getCameraFromKey(params.camera);
const camera = getCameraFromParams(params);
const [
photos,
@ -55,7 +55,7 @@ export default async function Share({
params,
searchParams,
}: CameraProps & PaginationParams) {
const cameraFromParams = getCameraFromKey(params.camera);
const cameraFromParams = getCameraFromParams(params);
const {
photos,

View File

@ -21,7 +21,7 @@ export default function CameraHeader({
const camera = cameraFromPhoto(photos[0], cameraProp);
return (
<PhotoSetHeader
entity={<PhotoCamera {...{ camera }} hideAppleIcon />}
entity={<PhotoCamera {...{ camera }} contrast="high" hideAppleIcon />}
entityVerb="Photo"
entityDescription={
descriptionForCameraPhotos(photos, undefined, count, dateRange)}

View File

@ -2,7 +2,9 @@ import { AiFillApple } from 'react-icons/ai';
import { pathForCamera } from '@/site/paths';
import { IoMdCamera } from 'react-icons/io';
import { Camera, formatCameraText } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function PhotoCamera({
camera,
@ -27,12 +29,12 @@ export default function PhotoCamera({
icon={showAppleIcon
? <AiFillApple
title="Apple"
className="translate-x-[-2.5px] translate-y-[2px]"
className="translate-x-[-0.5px]"
size={15}
/>
: <IoMdCamera
size={12}
className="translate-x-[-1px] translate-y-[3.5px]"
className="translate-x-[-1px]"
/>}
type={showAppleIcon && isCameraApple ? 'icon-first' : type}
badged={badged}

View File

@ -8,6 +8,14 @@ export type Camera = {
model: string
};
export interface CameraProps {
params: Camera
}
export interface PhotoCameraProps {
params: Camera & { photoId: string }
}
export type CameraWithCount = {
cameraKey: string
camera: Camera
@ -19,11 +27,16 @@ export type Cameras = CameraWithCount[];
export const createCameraKey = ({ make, model }: Camera) =>
parameterize(`${make}-${model}`, true);
// Assumes no makes ('Fujifilm,' 'Apple,' 'Canon', etc.) have dashes
export const getCameraFromKey = (cameraKey: string): Camera => {
const [make, model] = cameraKey.toLowerCase().split(/[-| ](.*)/s);
return { make, model };
};
export const getCameraFromParams = ({
make,
model,
}: {
make: string,
model: string,
}): Camera => ({
make: parameterize(make, true),
model: parameterize(model, true),
});
export const sortCamerasWithCount = (
a: CameraWithCount,
@ -36,36 +49,34 @@ export const sortCamerasWithCount = (
export const cameraFromPhoto = (
photo: Photo | undefined,
fallback?: Camera | string,
fallback?: Camera,
): Camera =>
photo?.make && photo?.model
? { make: photo.make, model: photo.model }
: typeof fallback === 'string'
? getCameraFromKey(fallback)
: fallback ?? CAMERA_PLACEHOLDER;
export const formatCameraText = (
{ make, model: modelRaw }: Camera,
includeMakeApple?: boolean,
{ make: makeRaw, model: modelRaw }: Camera,
includeMake: 'always' | 'never' | 'if-not-apple' = 'if-not-apple',
removeIPhoneOnLongModels?: boolean
) => {
// Remove 'Corporation' from makes like 'Nikon Corporation'
const make = makeRaw.replace(/ Corporation/i, '');
// Remove potential duplicate make from model
const model = modelRaw.replace(`${make} `, '');
return make === 'Apple' && !includeMakeApple
? model
let model = modelRaw.replace(`${make} `, '');
if (
removeIPhoneOnLongModels &&
model.includes('iPhone') &&
model.length > 9
) {
model = model.replace(/iPhone\s*/i, '');
}
return (
includeMake === 'never' ||
includeMake === 'if-not-apple' && make === 'Apple'
) ? model
: `${make} ${model}`;
};
export const formatCameraModelText = (
{ make, model: modelRaw }: Camera,
) => {
// Remove potential duplicate make from model
const model = modelRaw.replace(`${make} `, '');
const textLength = model?.length ?? 0;
if (textLength > 0 && textLength <= 8) {
return model;
} else if (model?.includes('iPhone')) {
return model.split('iPhone')[1];
} else {
return undefined;
}
};
export const formatCameraModelTextShort = (camera: Camera) =>
formatCameraText(camera, 'never', true);

View File

@ -27,8 +27,9 @@ export default function Badge({
);
case 'small':
return clsx(
'px-[0.3rem] py-1 rounded-[0.25rem]',
'text-[0.7rem] font-medium',
'h-max-baseline',
'px-[5px] py-[2.75px]',
'text-[0.7rem] font-medium rounded-[0.25rem]',
highContrast
? 'text-invert bg-invert'
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',

View File

@ -19,6 +19,7 @@ import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state/AppState';
import { getPhotoItemsAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
@ -38,16 +39,19 @@ export type CommandKSection = {
}
export default function CommandKClient({
sections = [],
serverSections = [],
showDebugTools,
footer,
}: {
sections?: CommandKSection[]
serverSections?: CommandKSection[]
showDebugTools?: boolean
footer?: string
}) {
const {
isCommandKOpen: isOpen,
setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands,
setShouldShowBaselineGrid,
} = useAppState();
const isOpenRef = useRef(isOpen);
@ -131,7 +135,7 @@ export default function CommandKClient({
}
}, [isOpen, setShouldRespondToKeyboardCommands]);
const sectionTheme: CommandKSection = {
const clientSections: CommandKSection[] = [{
heading: 'Theme',
accessory: <IoInvertModeSharp
size={14}
@ -150,7 +154,18 @@ export default function CommandKClient({
annotation: <BiMoon className="translate-x-[1px]" />,
action: () => setTheme('dark'),
}],
};
}];
if (showDebugTools) {
clientSections.push({
heading: 'Debug Tools',
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
items: [{
label: 'Toggle Baseline Grid',
action: () => setShouldShowBaselineGrid?.(prev => !prev),
}],
});
}
return (
<Command.Dialog
@ -203,8 +218,8 @@ export default function CommandKClient({
{isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty>
{queriedSections
.concat(sections)
.concat(sectionTheme)
.concat(serverSections)
.concat(clientSections)
.filter(({ items }) => items.length > 0)
.map(({ heading, accessory, items }) =>
<Command.Group

View File

@ -0,0 +1,24 @@
'use client';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { ReactNode } from 'react';
export default function DivDebugBaselineGrid({
children,
className,
}: {
children: ReactNode
className?: string
}) {
const { shouldShowBaselineGrid } = useAppState();
return (
<div className={clsx(
className,
shouldShowBaselineGrid && 'bg-baseline-grid',
)}>
{children}
</div>
);
}

View File

@ -1,100 +0,0 @@
import Link from 'next/link';
import { ReactNode } from 'react';
import Badge from './Badge';
import { clsx } from 'clsx/lite';
export interface EntityLinkExternalProps {
type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only'
badged?: boolean
contrast?: 'low' | 'medium' | 'high'
prefetch?: boolean
}
export default function EntityLink({
label,
labelSmall,
href,
icon,
title,
type = 'icon-first',
badged,
prefetch,
contrast = 'high',
hoverEntity,
}: {
label: ReactNode
labelSmall?: ReactNode
href: string
icon?: ReactNode
title?: string
hoverEntity?: ReactNode
} & EntityLinkExternalProps) {
const renderLabel = () => <>
<span className="xs:hidden">
{labelSmall ?? label}
</span>
<span className="hidden xs:inline-block">
{label}
</span>
</>;
const classForContrast = () => {
switch (contrast) {
case 'low':
return 'text-dim';
case 'high':
return 'text-main';
default:
return 'text-medium';
}
};
return (
<span className="group inline-flex items-center gap-2 h-5">
<Link
href={href}
title={title}
className={clsx(
'inline-flex gap-[0.23rem]',
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
classForContrast(),
)}
prefetch={prefetch}
>
{type !== 'icon-only' && <>
{badged
? <span className="h-6 inline-flex items-center">
<Badge
type="small"
highContrast={contrast === 'high'}
uppercase
interactive
>
{renderLabel()}
</Badge>
</span>
: <span className="uppercase">
{renderLabel()}
</span>}
</>}
{icon && type !== 'text-only' &&
<span className={clsx(
'flex-shrink-0',
'inline-flex min-w-[0.9rem]',
contrast === 'high'
? 'text-icon'
: classForContrast(),
type === 'icon-first' && 'order-first',
badged && 'translate-y-[4px]',
hoverEntity !== undefined && 'group-hover:hidden',
)}>
{icon}
</span>}
</Link>
{hoverEntity !== undefined &&
<span className="hidden group-hover:inline">
{hoverEntity}
</span>}
</span>
);
}

View File

@ -17,7 +17,7 @@ export default function HeaderList({
<AnimateItems
className={clsx(
className,
'space-y-0.5',
'space-y-1',
)}
scaleOffset={0.95}
duration={0.5}

View File

@ -1,11 +0,0 @@
'use client';
import { formatDate } from '@/utility/date';
export default function LocalDate({ date }: { date: Date }) {
return (
<>
{formatDate(date)}
</>
);
};

View File

@ -110,9 +110,8 @@ export default function OGTile({
/>}
</div>
<div className={clsx(
'md:text-lg',
'font-sans leading-tight',
'flex flex-col gap-1 p-3',
'font-sans leading-none',
'bg-gray-50 dark:bg-gray-900/50',
'group-active:bg-gray-50 group-active:dark:bg-gray-900/50',
'group-hover:bg-gray-100 group-hover:dark:bg-gray-900/70',

View File

@ -0,0 +1,20 @@
import { formatDate } from '@/utility/date';
export default function ResponsiveDate({
date,
}: {
date: Date
}) {
return (
<>
{/* Mobile */}
<span className="inline-block sm:hidden">
{formatDate(date, true)}
</span>
{/* Desktop */}
<span className="hidden sm:inline-block">
{formatDate(date)}
</span>
</>
);
}

View File

@ -24,7 +24,7 @@ export default function ShareModal({
<div className="space-y-3 md:space-y-4 w-full">
<div className={clsx(
'flex items-center gap-x-3',
'text-xl md:text-3xl leading-snug',
'text-2xl leading-snug',
)}>
<TbPhotoShare size={22} className="hidden xs:block" />
<div className="flex-grow">

View File

@ -0,0 +1,89 @@
import { ReactNode } from 'react';
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
import Badge from '../Badge';
import { clsx } from 'clsx/lite';
export interface EntityLinkExternalProps {
type?: LabeledIconType
badged?: boolean
contrast?: 'low' | 'medium' | 'high'
}
export default function EntityLink({
icon,
label,
labelSmall,
iconWide,
type,
badged,
contrast = 'medium',
href,
prefetch,
title,
hoverEntity,
debug,
}: {
icon: ReactNode
label: ReactNode
labelSmall?: ReactNode
iconWide?: boolean
href?: string
prefetch?: boolean
title?: string
hoverEntity?: ReactNode
debug?: boolean
} & EntityLinkExternalProps) {
const classForContrast = () => {
switch (contrast) {
case 'low':
return 'text-dim';
case 'high':
return 'text-main';
default:
return 'text-medium';
}
};
const renderLabel = () => <>
<span className="xs:hidden">
{labelSmall ?? label}
</span>
<span className="hidden xs:inline-block">
{label}
</span>
</>;
return (
<span className="group inline-flex gap-2">
<LabeledIcon {...{
icon,
iconWide,
href,
prefetch,
title,
type,
className: clsx(
classForContrast(),
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
),
debug,
}}>
{badged
? <Badge
type="small"
highContrast={contrast === 'high'}
className='translate-y-[-0.5px]'
uppercase
interactive
>
{renderLabel()}
</Badge>
: renderLabel()}
</LabeledIcon>
{hoverEntity !== undefined &&
<span className="hidden group-hover:inline">
{hoverEntity}
</span>}
</span>
);
}

View File

@ -0,0 +1,35 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx/lite';
import Spinner from '../Spinner';
export default function Icon({
children,
className,
iconClassName,
wide,
loading,
debug,
}: {
children: ReactNode
className?: string
iconClassName?: string
wide?: boolean
loading?: boolean
debug?: boolean,
}) {
return (
<span className={clsx(
'h-[18px] md:h-[20px]',
wide ? 'w-[28px]' : 'w-[14px]',
'inline-flex items-center justify-center',
debug && 'bg-gray-700',
className,
)}>
{loading
? <Spinner />
: <span className={iconClassName}>
{children}
</span>}
</span>
);
}

View File

@ -0,0 +1,60 @@
import { ComponentProps, ReactNode } from 'react';
import Icon from './Icon';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
export type LabeledIconType =
'icon-first' |
'icon-last' |
'icon-only' |
'text-only';
export default function LabeledIcon({
icon,
type = 'icon-first',
className: classNameProp,
children,
iconWide,
href,
prefetch,
debug,
}: {
icon?: ReactNode,
type?: LabeledIconType,
className?: string,
children: ReactNode,
iconWide?:boolean,
debug?: boolean,
} & Partial<ComponentProps<typeof Link>>) {
const className = clsx(
'inline-flex gap-x-1 md:gap-x-1.5',
classNameProp,
debug && 'border border-green-500 m-[-1px]',
);
const renderContent = () => <>
{icon && type !== 'text-only' &&
<Icon {...{
className: clsx(type === 'icon-last' && 'order-1'),
wide: iconWide,
debug,
}}>
{icon}
</Icon>}
{children && type !== 'icon-only' &&
<span className={clsx(
'uppercase',
debug && 'bg-gray-700'
)}>
{children}
</span>}
</>;
return href
? <Link {...{ href, prefetch, className }}>
{renderContent()}
</Link>
: <div {...{ className }}>
{renderContent()}
</div>;
}

View File

@ -5,7 +5,7 @@ import ImagePhotoGrid from './components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer';
import { OG_TEXT_BOTTOM_ALIGNMENT } from '@/site/config';
import { NextImageSize } from '@/services/next-image';
import { cameraFromPhoto, formatCameraModelText } from '@/camera';
import { cameraFromPhoto, formatCameraModelTextShort } from '@/camera';
export default function PhotoImageResponse({
photo,
@ -19,7 +19,7 @@ export default function PhotoImageResponse({
fontFamily: string
}) {
const model = photo.model
? formatCameraModelText(cameraFromPhoto(photo))
? formatCameraModelTextShort(cameraFromPhoto(photo))
: undefined;
return (

17
src/photo/PhotoDate.tsx Normal file
View File

@ -0,0 +1,17 @@
import ResponsiveDate from '@/components/ResponsiveDate';
import { Photo } from '.';
import { useMemo } from 'react';
export default function PhotoDate({
photo: { takenAtNaive },
}: {
photo: Photo
}) {
const date = useMemo(() => {
const date = new Date(takenAtNaive);
return isNaN(date.getTime()) ? new Date() : date;
}, [takenAtNaive]);
return (
<ResponsiveDate {...{ date }} />
);
}

View File

@ -18,6 +18,7 @@ import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import { sortTags } from '@/tag';
import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { Suspense } from 'react';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
export default function PhotoLarge({
photo,
@ -70,12 +71,11 @@ export default function PhotoLarge({
/>
</Link>}
contentSide={
<div className={clsx(
<DivDebugBaselineGrid className={clsx(
'relative',
'leading-snug',
'sticky top-4 self-start -translate-y-1',
'grid grid-cols-2 md:grid-cols-1',
'gap-x-0.5 sm:gap-x-1 gap-y-4',
'gap-x-0.5 sm:gap-x-1 gap-y-baseline',
'pb-6',
)}>
{/* Meta */}
@ -95,7 +95,7 @@ export default function PhotoLarge({
</div>
</Suspense>
</div>
<div className="space-y-4">
<div className="space-y-baseline">
{photo.caption &&
<div className="uppercase">
{photo.caption}
@ -113,7 +113,7 @@ export default function PhotoLarge({
</div>
</div>
{/* EXIF Data */}
<div className="space-y-4">
<div className="space-y-baseline">
{showExifContent &&
<>
<ul className="text-medium">
@ -141,8 +141,8 @@ export default function PhotoLarge({
/>}
</>}
<div className={clsx(
'flex gap-2',
'md:flex-col md:gap-4 md:justify-normal',
'flex gap-x-1.5 gap-y-baseline',
'md:flex-col md:justify-normal',
)}>
<div className={clsx(
'text-medium uppercase pr-1',
@ -161,7 +161,7 @@ export default function PhotoLarge({
/>
</div>
</div>
</div>}
</DivDebugBaselineGrid>}
/>
);
};

View File

@ -4,6 +4,7 @@ import ShareButton from '@/components/ShareButton';
import AnimateItems from '@/components/AnimateItems';
import { ReactNode } from 'react';
import { HIGH_DENSITY_GRID } from '@/site/config';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
export default function PhotoSetHeader({
entity,
@ -35,7 +36,7 @@ export default function PhotoSetHeader({
type="bottom"
distanceOffset={10}
animateOnFirstLoadOnly
items={[<div
items={[<DivDebugBaselineGrid
key="PhotosHeader"
className={clsx(
'grid gap-0.5 sm:gap-1 items-start',
@ -74,7 +75,7 @@ export default function PhotoSetHeader({
? start
: <>{end}<br /> {start}</>}
</span>
</div>]}
</DivDebugBaselineGrid>]}
/>
);
}

View File

@ -36,7 +36,6 @@ import { extractExifDataFromBlobPath } from './server';
import { TAG_FAVS, isTagFavs } from '@/tag';
import { TbPhoto } from 'react-icons/tb';
import PhotoTiny from './PhotoTiny';
import { formatDate } from '@/utility/date';
import {
convertPhotoToPhotoDbInsert,
getKeywordsForPhoto,
@ -45,6 +44,7 @@ import {
import { safelyRunAdminServerAction } from '@/auth';
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/services/openai';
import PhotoDate from './PhotoDate';
export async function createPhotoAction(formData: FormData) {
return safelyRunAdminServerAction(async () => {
@ -211,14 +211,7 @@ export async function getPhotoItemsAction(query: string) {
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <>
<span className="hidden sm:inline-block">
{formatDate(photo.takenAt)}
</span>
<span className="inline-block sm:hidden">
{formatDate(photo.takenAt, true)}
</span>
</>,
annotation: <PhotoDate {...{ photo }} />,
accessory: <PhotoTiny photo={photo} />,
path: pathForPhoto(photo),
})),

View File

@ -41,6 +41,7 @@ export default function AiButton({
return (
<button
type="button"
className={clsx(
'flex min-w-[3.25rem] min-h-9 justify-center',
className,

View File

@ -211,7 +211,7 @@ const sqlGetPhotosCameraMeta = async (camera: Camera) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE
LOWER(make)=${parameterize(camera.make, true)} AND
LOWER(REPLACE(make, ' ', '-'))=${parameterize(camera.make, true)} AND
LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model, true)} AND
hidden IS NOT TRUE
`.then(({ rows }) => ({
@ -381,7 +381,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
values.push(tag);
}
if (camera) {
wheres.push(`LOWER(make)=$${valueIndex++}`);
wheres.push(`LOWER(REPLACE(make, ' ', '-'))=$${valueIndex++}`);
wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valueIndex++}`);
values.push(parameterize(camera.make, true));
values.push(parameterize(camera.model, true));

View File

@ -2,7 +2,9 @@ import { labelForFilmSimulation } from '@/vendors/fujifilm';
import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon';
import { pathForFilmSimulation } from '@/site/paths';
import { FilmSimulation } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function PhotoFilmSimulation({
simulation,
@ -22,16 +24,14 @@ export default function PhotoFilmSimulation({
label={medium}
labelSmall={small}
href={pathForFilmSimulation(simulation)}
icon={<PhotoFilmSimulationIcon
simulation={simulation}
className="translate-y-[-1px]"
/>}
icon={<PhotoFilmSimulationIcon simulation={simulation} />}
title={`Film Simulation: ${large}`}
type={type}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
iconWide
/>
);
}

View File

@ -6,6 +6,7 @@ import {
getUniqueTagsCached,
} from '@/photo/cache';
import {
PATH_ADMIN_BASELINE,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
@ -26,6 +27,7 @@ import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAction } from '@/auth/actions';
import { ADMIN_DEBUG_TOOLS_ENABLED } from './config';
export default async function CommandK() {
const [
@ -124,14 +126,22 @@ export default async function CommandK() {
}],
};
if (isAdminLoggedIn && ADMIN_DEBUG_TOOLS_ENABLED) {
SECTION_ADMIN.items.push({
label: 'Baseline Overview',
path: PATH_ADMIN_BASELINE,
});
}
return <CommandKClient
sections={[
serverSections={[
SECTION_TAGS,
SECTION_CAMERAS,
SECTION_FILM,
SECTION_PAGES,
SECTION_ADMIN,
]}
showDebugTools={isAdminLoggedIn && ADMIN_DEBUG_TOOLS_ENABLED}
footer={photoQuantityText(count, false)}
/>;
}

View File

@ -38,10 +38,7 @@ export default function FooterClient({
'flex items-center',
'text-dim min-h-10',
)}>
<div className={clsx(
'flex items-center flex-grow flex-wrap h-10',
'gap-x-4 min-w-0',
)}>
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&

View File

@ -57,7 +57,6 @@ export default function NavClient({
className={clsx(
'flex items-center',
'w-full min-h-[4rem]',
'leading-none',
)}>
<div className="flex-grow">
<ViewSwitcher

View File

@ -139,7 +139,7 @@ export default function SiteChecklistClient({
</div>;
return (
<div className="text-sm max-w-xl space-y-6 w-full">
<div className="max-w-xl space-y-6 w-full">
<Checklist
title="Storage"
icon={<BiData size={16} />}

View File

@ -117,8 +117,8 @@ export const GRID_ASPECT_RATIO =
? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO)
: 1;
export const OG_TEXT_BOTTOM_ALIGNMENT =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '')
.toUpperCase() === 'BOTTOM';
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
export const ADMIN_DEBUG_TOOLS_ENABLED = process.env.ADMIN_DEBUG_TOOLS === '1';
export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1;

View File

@ -116,7 +116,7 @@
hover:text-gray-600
hover:dark:text-gray-400
}
/* Common Utilities: Text */
/* Utilities: Text */
.text-main {
@apply
text-gray-900 dark:text-gray-100
@ -145,7 +145,7 @@
@apply
text-red-500 dark:text-red-400
}
/* Common Utilities: Background */
/* Utilities: Background */
.bg-main {
@apply
bg-white dark:bg-black
@ -159,4 +159,28 @@
@apply
bg-gray-900 dark:bg-gray-100
}
/* Utilities: Baseline Grid */
.space-y-baseline {
@apply
space-y-[1.1875rem] md:space-y-[1.25rem]
}
.gap-y-baseline {
@apply
gap-y-[1.1875rem] md:gap-y-[1.25rem]
}
.gap-baseline {
@apply
gap-[1.1875rem] md:gap-[1.25rem]
}
.max-h-baseline {
@apply
max-h-[1.1875rem] md:max-h-[1.25rem]
}
.bg-baseline-grid {
@apply
bg-[repeating-linear-gradient(to_bottom,#eee,#eee_1px,transparent_1px,transparent_1.1875rem)]
md:bg-[repeating-linear-gradient(to_bottom,#eee,#eee_1px,transparent_1px,transparent_1.25rem)]
dark:bg-[repeating-linear-gradient(to_bottom,#222,#222_1px,transparent_1px,transparent_1.1875rem)]
dark:md:bg-[repeating-linear-gradient(to_bottom,#222,#222_1px,transparent_1px,transparent_1.25rem)]
}
}

View File

@ -1,11 +1,8 @@
import { Photo } from '@/photo';
import { BASE_URL } from './config';
import {
Camera,
createCameraKey,
getCameraFromKey,
} from '@/camera';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { parameterize } from '@/utility/string';
// Core paths
export const PATH_ROOT = '/';
@ -24,7 +21,7 @@ export const PREFIX_FILM_SIMULATION = '/film';
// Dynamic paths
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[camera]`;
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
// Admin paths
@ -32,6 +29,7 @@ export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
// API paths
export const PATH_API_STORAGE = `${PATH_API}/storage`;
@ -125,8 +123,11 @@ export const pathForTag = (tag: string, next?: number) =>
export const pathForTagShare = (tag: string) =>
`${pathForTag(tag)}/${SHARE}`;
export const pathForCamera = (camera: Camera, next?: number) =>
pathWithNext(`${PREFIX_CAMERA}/${createCameraKey(camera)}`, next);
export const pathForCamera = ({ make, model }: Camera, next?: number) =>
pathWithNext(
`${PREFIX_CAMERA}/${parameterize(make, true)}/${parameterize(model, true)}`,
next,
);
export const pathForCameraShare = (camera: Camera) =>
`${pathForCamera(camera)}/${SHARE}`;
@ -195,22 +196,22 @@ export const isPathTagPhoto = (pathname = '') =>
export const isPathTagPhotoShare = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname);
// shot-on/[camera]
// shot-on/[make]/[model]
export const isPathCamera = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/?$`).test(pathname);
// shot-on/[camera]/share
export const isPathCameraShare = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/${SHARE}/?$`).test(pathname);
// shot-on/[camera]/[photoId]
export const isPathCameraPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
// shot-on/[camera]/[photoId]/share
export const isPathCameraPhotoShare = (pathname = '') =>
// shot-on/[make]/[model]/share
export const isPathCameraShare = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname);
// shot-on/[make]/[model]/[photoId]
export const isPathCameraPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(pathname);
// shot-on/[make]/[model]/[photoId]/share
export const isPathCameraPhotoShare = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/${SHARE}/?$`).test(pathname);
// film/[simulation]
export const isPathFilmSimulation = (pathname = '') =>
new RegExp(`^${PREFIX_FILM_SIMULATION}/[^/]+/?$`).test(pathname);
@ -257,18 +258,20 @@ export const getPathComponents = (pathname = ''): {
const photoIdFromTag = pathname.match(
new RegExp(`^${PREFIX_TAG}/[^/]+/((?!${SHARE})[^/]+)`))?.[1];
const photoIdFromCamera = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/((?!${SHARE})[^/]+)`))?.[1];
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/((?!${SHARE})[^/]+)`))?.[1];
const photoIdFromFilmSimulation = pathname.match(
new RegExp(`^${PREFIX_FILM_SIMULATION}/[^/]+/((?!${SHARE})[^/]+)`))?.[1];
const tag = pathname.match(
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
const cameraString = pathname.match(
const cameraMake = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1];
const cameraModel = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1];
const simulation = pathname.match(
new RegExp(`^${PREFIX_FILM_SIMULATION}/([^/]+)`))?.[1] as FilmSimulation;
const camera = cameraString
? getCameraFromKey(cameraString)
const camera = cameraMake && cameraModel
? { make: cameraMake, model: cameraModel }
: undefined;
return {

View File

@ -11,6 +11,8 @@ export interface AppStateContext {
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
shouldShowBaselineGrid?: boolean
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
clearNextPhotoAnimation?: () => void
}

View File

@ -12,15 +12,16 @@ export default function AppStateProvider({
}) {
const { previousPathname } = usePathnames();
const [hasLoaded, setHasLoaded] = useState(false);
const [hasLoaded, setHasLoaded] =
useState(false);
const [nextPhotoAnimation, setNextPhotoAnimation] =
useState<AnimationConfig>();
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
useState(true);
const [isCommandKOpen, setIsCommandKOpen] = useState(false);
const [isCommandKOpen, setIsCommandKOpen] =
useState(false);
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
useState(false);
useEffect(() => {
setHasLoaded?.(true);
@ -38,6 +39,8 @@ export default function AppStateProvider({
setShouldRespondToKeyboardCommands,
isCommandKOpen,
setIsCommandKOpen,
shouldShowBaselineGrid,
setShouldShowBaselineGrid,
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
}}
>

View File

@ -1,8 +1,10 @@
import { FaStar } from 'react-icons/fa';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import { TAG_FAVS } from '.';
import { pathForTag } from '@/site/paths';
import { clsx } from 'clsx/lite';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function FavsTag({
type,
@ -31,7 +33,7 @@ export default function FavsTag({
size={12}
className={clsx(
'text-amber-500',
'translate-x-[-1px] translate-y-[3.5px]',
'translate-x-[-1px] translate-y-[-0.5px]',
)}
/>}
type={type}

View File

@ -1,7 +1,9 @@
import { pathForTag } from '@/site/paths';
import { FaTag } from 'react-icons/fa';
import { formatTag } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function PhotoTag({
tag,
@ -20,7 +22,7 @@ export default function PhotoTag({
href={pathForTag(tag)}
icon={<FaTag
size={11}
className="translate-y-[5px]"
className="translate-y-[1px]"
/>}
type={type}
badged={badged}

View File

@ -1,7 +1,7 @@
import PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.';
import FavsTag from './FavsTag';
import { EntityLinkExternalProps } from '@/components/EntityLink';
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
export default function PhotoTags({
tags,
@ -10,13 +10,13 @@ export default function PhotoTags({
tags: string[]
} & EntityLinkExternalProps) {
return (
<div className="-space-y-0.5">
<div className="flex flex-col">
{tags.map(tag =>
<div key={tag}>
<>
{isTagFavs(tag)
? <FavsTag {...{ contrast }} />
: <PhotoTag {...{ tag, contrast }} />}
</div>)}
? <FavsTag key={tag} {...{ contrast }} />
: <PhotoTag key={tag} {...{ tag, contrast }} />}
</>)}
</div>
);
}

View File

@ -22,7 +22,7 @@ export default function TagHeader({
<PhotoSetHeader
entity={isTagFavs(tag)
? <FavsTag />
: <PhotoTag tag={tag} />}
: <PhotoTag tag={tag} contrast="high" />}
entityVerb="Tagged"
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
photos={photos}

View File

@ -1,13 +1,13 @@
import { format, parseISO, parse } from 'date-fns';
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
const DATE_STRING_FORMAT = 'd MMM yyyy h:mma';
const DATE_STRING_FORMAT = 'dd MMM yyyy h:mma';
const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss';
type AmbiguousTimestamp = number | string;
export const formatDate = (date: Date, short?: boolean) =>
format(date, short? DATE_STRING_FORMAT_SHORT : DATE_STRING_FORMAT);
format(date, short ? DATE_STRING_FORMAT_SHORT : DATE_STRING_FORMAT);
export const formatDateFromPostgresString = (date: string, short?: boolean) =>
formatDate(parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()), short);

View File

@ -12,13 +12,13 @@ module.exports = {
...defaultTheme.screens,
},
fontSize: {
'xs': '0.75rem',
'sm': ['0.825rem', '1.15rem'],
'base': ['0.875rem', '1.275rem'],
'lg': ['0.925rem', '1.05rem'],
'xl': '1rem',
'2xl': '1.1rem',
'3xl': ['1.3rem', '1.7rem'],
'xs': ['0.75rem', '1rem'], // 12px on 16px
'sm': ['0.84375rem', '1.1875rem'], // 13.5px on 19px [Default: mobile]
'base': ['0.875rem', '1.25rem'], // 14px on 20px [Default: desktop]
'lg': ['1rem', '1.25rem'], // 16px on 20px
'xl': ['1.125rem', '1.25rem'], // 18px on 20px
'2xl': ['1.25rem', '1.25rem'], // 20px on 20px
'3xl': ['1.5rem', '1.5rem'], // 24px on 24px
},
extend: {
fontFamily: {