Refactor photo set customization

This commit is contained in:
Sam Becker 2025-03-07 18:07:08 -06:00
parent 21ed815cba
commit 6738ffc28e
38 changed files with 464 additions and 294 deletions

View File

@ -129,10 +129,8 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
- `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
- `NEXT_PUBLIC_CAMERAS_FIRST = 1` shows cameras above tags in grid sidebar
- `NEXT_PUBLIC_CATEGORY_VISIBILITY` controls which photos sets appear in the grid sidebar and CMD-K menu, and in what order. Default value is `tags,cameras,recipes,films`. As an example, you could move cameras above tags, and hide film simulations, by updating this value to `cameras,tags,recipes`.
#### Grid
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
@ -286,6 +284,9 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
#### 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.
#### How do I hide Fujifilm content such as a recipes and film simulations?
> This can be accomplished by setting `NEXT_PUBLIC_CATEGORY_VISIBILITY` (which has a default value of `tags, cameras, recipes, simulations`) to simply `tags, cameras`.
#### 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.

37
__tests__/set.test.ts Normal file
View File

@ -0,0 +1,37 @@
import {
DEFAULT_CATEGORY_KEYS,
getOrderedCategoriesFromString,
} from '@/photo/set';
describe('set', () => {
it('parses from string', () => {
expect(getOrderedCategoriesFromString())
.toStrictEqual(DEFAULT_CATEGORY_KEYS);
expect(getOrderedCategoriesFromString(
'cameras,recipes,tags,films,focal-lengths,lenses',
)).toStrictEqual([
'cameras',
'recipes',
'tags',
'films',
'focal-lengths',
'lenses',
]);
expect(getOrderedCategoriesFromString(
'cameras, recipes, tags, films',
)).toStrictEqual([
'cameras',
'recipes',
'tags',
'films',
]);
expect(getOrderedCategoriesFromString(
'cameras',
)).toStrictEqual([
'cameras',
]);
});
});

View File

@ -63,7 +63,7 @@
"@types/sanitize-html": "^2.13.0",
"clsx": "^2.1.1",
"cross-fetch": "^4.1.0",
"eslint": "9.21.0",
"eslint": "9.22.0",
"eslint-config-next": "15.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^29.7.0",

121
pnpm-lock.yaml generated
View File

@ -166,14 +166,14 @@ importers:
specifier: ^4.1.0
version: 4.1.0
eslint:
specifier: 9.21.0
version: 9.21.0(jiti@2.4.2)
specifier: 9.22.0
version: 9.22.0(jiti@2.4.2)
eslint-config-next:
specifier: 15.2.1
version: 15.2.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
version: 15.2.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
eslint-plugin-react-hooks:
specifier: ^5.2.0
version: 5.2.0(eslint@9.21.0(jiti@2.4.2))
version: 5.2.0(eslint@9.22.0(jiti@2.4.2))
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
@ -609,6 +609,10 @@ packages:
resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.1.0':
resolution: {integrity: sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@0.12.0':
resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -617,8 +621,8 @@ packages:
resolution: {integrity: sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.21.0':
resolution: {integrity: sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==}
'@eslint/js@9.22.0':
resolution: {integrity: sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6':
@ -2425,8 +2429,8 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
eslint-scope@8.2.0:
resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==}
eslint-scope@8.3.0:
resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@3.4.3:
@ -2437,8 +2441,8 @@ packages:
resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint@9.21.0:
resolution: {integrity: sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==}
eslint@9.22.0:
resolution: {integrity: sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
@ -5035,9 +5039,9 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.4.1(eslint@9.21.0(jiti@2.4.2))':
'@eslint-community/eslint-utils@4.4.1(eslint@9.22.0(jiti@2.4.2))':
dependencies:
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
@ -5050,6 +5054,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.1.0': {}
'@eslint/core@0.12.0':
dependencies:
'@types/json-schema': 7.0.15
@ -5068,7 +5074,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@eslint/js@9.21.0': {}
'@eslint/js@9.22.0': {}
'@eslint/object-schema@2.1.6': {}
@ -6271,15 +6277,15 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)':
'@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/parser': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/scope-manager': 8.24.1
'@typescript-eslint/type-utils': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/utils': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/type-utils': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/utils': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/visitor-keys': 8.24.1
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@ -6288,14 +6294,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)':
'@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.24.1
'@typescript-eslint/types': 8.24.1
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.2)
'@typescript-eslint/visitor-keys': 8.24.1
debug: 4.4.0
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
typescript: 5.8.2
transitivePeerDependencies:
- supports-color
@ -6305,12 +6311,12 @@ snapshots:
'@typescript-eslint/types': 8.24.1
'@typescript-eslint/visitor-keys': 8.24.1
'@typescript-eslint/type-utils@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)':
'@typescript-eslint/type-utils@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)':
dependencies:
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.2)
'@typescript-eslint/utils': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/utils': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
debug: 4.4.0
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
ts-api-utils: 2.0.1(typescript@5.8.2)
typescript: 5.8.2
transitivePeerDependencies:
@ -6332,13 +6338,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)':
'@typescript-eslint/utils@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)':
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0(jiti@2.4.2))
'@eslint-community/eslint-utils': 4.4.1(eslint@9.22.0(jiti@2.4.2))
'@typescript-eslint/scope-manager': 8.24.1
'@typescript-eslint/types': 8.24.1
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.2)
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
typescript: 5.8.2
transitivePeerDependencies:
- supports-color
@ -7025,19 +7031,19 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
eslint-config-next@15.2.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2):
eslint-config-next@15.2.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2):
dependencies:
'@next/eslint-plugin-next': 15.2.1
'@rushstack/eslint-patch': 1.10.5
'@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/parser': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
eslint: 9.21.0(jiti@2.4.2)
'@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/parser': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
eslint: 9.22.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.21.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.1)(eslint@9.21.0(jiti@2.4.2))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.21.0(jiti@2.4.2))
eslint-plugin-react: 7.37.4(eslint@9.21.0(jiti@2.4.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.21.0(jiti@2.4.2))
eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.1)(eslint@9.22.0(jiti@2.4.2))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@2.4.2))
eslint-plugin-react: 7.37.4(eslint@9.22.0(jiti@2.4.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.22.0(jiti@2.4.2))
optionalDependencies:
typescript: 5.8.2
transitivePeerDependencies:
@ -7053,33 +7059,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.8.1(eslint-plugin-import@2.31.0)(eslint@9.21.0(jiti@2.4.2)):
eslint-import-resolver-typescript@3.8.1(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
enhanced-resolve: 5.18.1
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
get-tsconfig: 4.10.0
is-bun-module: 1.3.0
stable-hash: 0.0.4
tinyglobby: 0.2.11
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.1)(eslint@9.21.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.1)(eslint@9.22.0(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.21.0(jiti@2.4.2)):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.22.0(jiti@2.4.2)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
eslint: 9.21.0(jiti@2.4.2)
'@typescript-eslint/parser': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
eslint: 9.22.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.21.0(jiti@2.4.2))
eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.1)(eslint@9.21.0(jiti@2.4.2)):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.1)(eslint@9.22.0(jiti@2.4.2)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@ -7088,9 +7094,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.21.0(jiti@2.4.2))
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.22.0(jiti@2.4.2))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -7102,13 +7108,13 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/parser': 8.24.1(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jsx-a11y@6.10.2(eslint@9.21.0(jiti@2.4.2)):
eslint-plugin-jsx-a11y@6.10.2(eslint@9.22.0(jiti@2.4.2)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.8
@ -7118,7 +7124,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@ -7127,11 +7133,11 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@5.2.0(eslint@9.21.0(jiti@2.4.2)):
eslint-plugin-react-hooks@5.2.0(eslint@9.22.0(jiti@2.4.2)):
dependencies:
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
eslint-plugin-react@7.37.4(eslint@9.21.0(jiti@2.4.2)):
eslint-plugin-react@7.37.4(eslint@9.22.0(jiti@2.4.2)):
dependencies:
array-includes: 3.1.8
array.prototype.findlast: 1.2.5
@ -7139,7 +7145,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.1
eslint: 9.21.0(jiti@2.4.2)
eslint: 9.22.0(jiti@2.4.2)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@ -7153,7 +7159,7 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-scope@8.2.0:
eslint-scope@8.3.0:
dependencies:
esrecurse: 4.3.0
estraverse: 5.3.0
@ -7162,14 +7168,15 @@ snapshots:
eslint-visitor-keys@4.2.0: {}
eslint@9.21.0(jiti@2.4.2):
eslint@9.22.0(jiti@2.4.2):
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0(jiti@2.4.2))
'@eslint-community/eslint-utils': 4.4.1(eslint@9.22.0(jiti@2.4.2))
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.19.2
'@eslint/config-helpers': 0.1.0
'@eslint/core': 0.12.0
'@eslint/eslintrc': 3.3.0
'@eslint/js': 9.21.0
'@eslint/js': 9.22.0
'@eslint/plugin-kit': 0.2.7
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
@ -7181,7 +7188,7 @@ snapshots:
cross-spawn: 7.0.6
debug: 4.4.0
escape-string-regexp: 4.0.0
eslint-scope: 8.2.0
eslint-scope: 8.3.0
eslint-visitor-keys: 4.2.0
espree: 10.3.0
esquery: 1.6.0

View File

@ -2,6 +2,7 @@
import {
ComponentProps,
Fragment,
ReactNode,
} from 'react';
import ChecklistRow from '../components/ChecklistRow';
@ -28,6 +29,8 @@ import { CgDebug } from 'react-icons/cg';
import EnvVar from '@/components/EnvVar';
import AdminLink from './AdminLink';
import ScoreCardContainer from '@/components/ScoreCardContainer';
import { capitalize } from '@/utility/string';
import { DEFAULT_CATEGORY_KEYS, getHiddenDefaultCategories } from '@/photo/set';
export default function AdminAppConfigurationClient({
// Storage
@ -60,8 +63,8 @@ export default function AdminAppConfigurationClient({
arePhotoCategoriesStaticallyOptimized,
arePhotoCategoryOgImagesStaticallyOptimized,
areOriginalUploadsPreserved,
imageQuality,
hasImageQuality,
imageQuality,
isBlurEnabled,
// Visual
hasDefaultTheme,
@ -72,10 +75,9 @@ export default function AdminAppConfigurationClient({
showZoomControls,
showTakenAtTimeHidden,
showSocial,
showFilmSimulations,
showRecipes,
showRepoLink,
showSidebarCamerasFirst,
hasCategoryVisibility,
categoryVisibility,
// Grid
isGridHomepageEnabled,
gridAspectRatio,
@ -517,25 +519,6 @@ export default function AdminAppConfigurationClient({
X (formerly Twitter) button from share modal:
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm simulations"
status={showFilmSimulations}
optional
>
Set environment variable to {'"1"'} to prevent
simulations showing up in /grid sidebar and
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}
@ -545,13 +528,40 @@ export default function AdminAppConfigurationClient({
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
</ChecklistRow>
<ChecklistRow
title="Show cameras first"
status={showSidebarCamerasFirst}
title={hasCategoryVisibility
? `Category visibility: ${categoryVisibility.join(',')}`
: 'Category visibility'}
status={hasCategoryVisibility}
optional
>
Set environment variable to {'"1"'} to show cameras
above tags in grid sidebar:
{renderEnvVars(['NEXT_PUBLIC_CAMERAS_FIRST'])}
{categoryVisibility.map((category, index) =>
<Fragment key={category}>
{renderSubStatus(
'checked',
<>
{index + 1}
{'.'}
{capitalize(category)}
</>,
)}
</Fragment>)}
{getHiddenDefaultCategories(categoryVisibility)
.map((category, index) =>
<Fragment key={category}>
{renderSubStatus(
'optional',
<span className="text-dim">
{categoryVisibility.length + index + 1}
{'.'}
{capitalize(category)}
</span>,
)}
</Fragment>)}
Configure photo category visibility and order
(seen in grid sidebar and CMD-K results)
by storing comma-separated values
(default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}):
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
</ChecklistRow>
</ChecklistGroup>
<ChecklistGroup

View File

@ -3,15 +3,16 @@ import {
getUniqueCameras,
getUniqueFilmSimulations,
getUniqueFocalLengths,
getUniqueRecipes,
getUniqueTags,
} from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient';
import {
APP_CONFIGURATION,
CATEGORY_VISIBILITY,
GRID_HOMEPAGE_ENABLED,
HAS_STATIC_OPTIMIZATION,
MATTE_PHOTOS,
SHOW_SIDEBAR_CAMERAS_FIRST,
} from '@/app/config';
import { getGitHubMetaForCurrentApp, getSignificantInsights } from '.';
import { getOutdatedPhotosCount } from '@/photo/db/query';
@ -27,6 +28,7 @@ export default async function AdminAppInsights() {
{ count: photosCountPortrait },
tags,
cameras,
recipes,
filmSimulations,
focalLengths,
codeMeta,
@ -37,6 +39,7 @@ export default async function AdminAppInsights() {
getPhotosMeta({ maximumAspectRatio: 0.9 }),
getUniqueTags(),
getUniqueCameras(),
getUniqueRecipes(),
getUniqueFilmSimulations(),
getUniqueFocalLengths(),
getGitHubMetaForCurrentApp(),
@ -67,7 +70,7 @@ export default async function AdminAppInsights() {
photoMatting: photosCountPortrait > 0 && !MATTE_PHOTOS,
camerasFirst: (
tags.length > TAG_COUNT_THRESHOLD &&
!SHOW_SIDEBAR_CAMERAS_FIRST
CATEGORY_VISIBILITY[0] !== 'cameras'
),
gridFirst: (
photosCount >= BASIC_PHOTO_INSTALLATION_COUNT &&
@ -81,6 +84,7 @@ export default async function AdminAppInsights() {
photosCountOutdated,
tagsCount: tags.length,
camerasCount: cameras.length,
recipesCount: recipes.length,
filmSimulationsCount: filmSimulations.length,
focalLengthsCount: focalLengths.length,
dateRange,

View File

@ -11,7 +11,7 @@ import { HiMiniArrowsUpDown } from 'react-icons/hi2';
import { HiOutlinePhotograph } from 'react-icons/hi';
import { MdAspectRatio } from 'react-icons/md';
import { PiWarningBold } from 'react-icons/pi';
import { TbCone, TbSparkles } from 'react-icons/tb';
import { TbChecklist, TbCone, TbSparkles } from 'react-icons/tb';
import { BiGitBranch, BiGitCommit, BiLogoGithub } from 'react-icons/bi';
import {
TEMPLATE_REPO_BRANCH,
@ -21,6 +21,7 @@ import {
VERCEL_GIT_COMMIT_MESSAGE,
TEMPLATE_REPO_URL_FORK,
TEMPLATE_REPO_URL_README,
CATEGORY_VISIBILITY,
} from '@/app/config';
import {
AdminAppInsights,
@ -89,6 +90,7 @@ export default function AdminAppInsightsClient({
photosCountOutdated,
tagsCount,
camerasCount,
recipesCount,
filmSimulationsCount,
focalLengthsCount,
dateRange,
@ -134,10 +136,10 @@ export default function AdminAppInsightsClient({
className={TEXT_COLOR_WARNING}
/>}
content={<>
Could not analyze source code
<span>Could not analyze source code</span>
<Tooltip
content="Could not connect to GitHub API. Try refreshing."
classNameTrigger="translate-y-[4.5px] ml-2 h-3"
classNameTrigger="translate-y-[-1.5px] ml-2 h-3"
/>
</>}
/>}
@ -357,8 +359,8 @@ export default function AdminAppInsightsClient({
showing cameras first in the sidebar by setting
{' '}
<EnvVar
variable="SHOW_SIDEBAR_CAMERAS_FIRST"
value="1"
variable="NEXT_PUBLIC_CATEGORY_VISIBILITY"
value="cameras, tags, recipes, films"
trailingContent="."
/>
</>}
@ -411,30 +413,52 @@ export default function AdminAppInsightsClient({
{photosCountHidden > 0 && ` (${photosCountHidden} hidden)`}
</>}
/>
<ScoreCardRow
{CATEGORY_VISIBILITY.map(category => {
switch (category) {
case 'tags':
return <ScoreCardRow
key={category}
icon={<FaTag
size={12}
className="translate-y-[3px]"
/>}
content={pluralize(tagsCount, 'tag')}
/>
<ScoreCardRow
/>;
case 'cameras':
return <ScoreCardRow
key={category}
icon={<FaCamera
size={13}
className="translate-y-[2px]"
/>}
content={pluralize(camerasCount, 'camera')}
/>
{filmSimulationsCount > 0 &&
<ScoreCardRow
/>;
case 'recipes':
if (recipesCount > 0) {
return <ScoreCardRow
key={category}
icon={<TbChecklist
size={18}
className="translate-y-[-0.5px]"
/>}
content={pluralize(recipesCount, 'recipe')}
/>;
}
case 'films':
if (filmSimulationsCount > 0) {
return <ScoreCardRow
key={category}
icon={<span className="inline-flex w-3">
<PhotoFilmSimulationIcon
className="shrink-0 translate-x-[-1px] translate-y-[-0.5px]"
className="shrink-0 translate-x-[-2px] translate-y-[-0.5px]"
height={18}
/>
</span>}
content={pluralize(filmSimulationsCount, 'film simulation')}
/>}
/>;
}
}
})}
<ScoreCardRow
icon={<TbCone className="rotate-[270deg] translate-x-[-2px]" />}
content={pluralize(focalLengthsCount, 'focal length')}

View File

@ -50,6 +50,7 @@ export interface PhotoStats {
photosCountOutdated: number
tagsCount: number
camerasCount: number
recipesCount: number
filmSimulationsCount: number
focalLengthsCount: number
dateRange?: PhotoDateRange

View File

@ -1,33 +1,25 @@
import CommandKClient, {
CommandKSection,
} from '@/components/cmdk/CommandKClient';
import CommandKClient from '@/components/cmdk/CommandKClient';
import {
getPhotosMetaCached,
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueRecipesCached,
getUniqueTagsCached,
} from '@/photo/cache';
import {
pathForCamera,
pathForFilmSimulation,
pathForFocalLength,
} from './paths';
import { formatCameraText } from '@/camera';
import { photoQuantityText } from '@/photo';
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/simulation';
import {
ADMIN_DEBUG_TOOLS_ENABLED,
SHOW_FILM_SIMULATIONS,
SHOW_RECIPES,
} from './config';
import { getUniqueFocalLengths } from '@/photo/db/query';
import { formatFocalLength } from '@/focal';
import { TbCone } from 'react-icons/tb';
export default async function CommandK() {
const [
count,
tags,
cameras,
recipes,
filmSimulations,
focalLengths,
] = await Promise.all([
@ -36,56 +28,21 @@ export default async function CommandK() {
.catch(() => 0),
getUniqueTagsCached().catch(() => []),
getUniqueCamerasCached().catch(() => []),
SHOW_RECIPES
? getUniqueRecipesCached().catch(() => [])
: [],
SHOW_FILM_SIMULATIONS
? getUniqueFilmSimulationsCached().catch(() => [])
: [],
getUniqueFocalLengths().catch(() => []),
]);
const SECTION_CAMERAS: CommandKSection = {
heading: 'Cameras',
accessory: <IoMdCamera />,
items: cameras.map(({ camera, count }) => ({
label: formatCameraText(camera),
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForCamera(camera),
})),
};
const SECTION_FILM: CommandKSection = {
heading: 'Film Simulations',
accessory: <span className="w-3">
<PhotoFilmSimulationIcon className="translate-y-[0.5px]" />
</span>,
items: filmSimulations.map(({ simulation, count }) => ({
label: labelForFilmSimulation(simulation).medium,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForFilmSimulation(simulation),
})),
};
const SECTION_FOCAL: CommandKSection = {
heading: 'Focal Lengths',
accessory: <TbCone
className="rotate-[270deg] text-[14px]"
/>,
items: focalLengths.map(({ focal, count }) => ({
label: formatFocalLength(focal)!,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForFocalLength(focal),
})),
};
return <CommandKClient
tags={tags}
serverSections={[
SECTION_CAMERAS,
SECTION_FILM,
SECTION_FOCAL,
]}
cameras={cameras}
simulations={filmSimulations}
recipes={recipes}
focalLengths={focalLengths}
showDebugTools={ADMIN_DEBUG_TOOLS_ENABLED}
footer={photoQuantityText(count, false)}
/>;

View File

@ -2,6 +2,7 @@ import {
AI_AUTO_GENERATED_FIELDS_DEFAULT,
parseAiAutoGeneratedFieldsString,
} from '@/photo/ai';
import { getOrderedCategoriesFromString } from '@/photo/set';
import type { StorageType } from '@/platforms/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
@ -207,6 +208,12 @@ export const MATTE_PHOTOS =
// DISPLAY
export const CATEGORY_VISIBILITY = getOrderedCategoriesFromString(
process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY);
export const SHOW_RECIPES =
CATEGORY_VISIBILITY.includes('recipes');
export const SHOW_FILM_SIMULATIONS =
CATEGORY_VISIBILITY.includes('films');
export const SHOW_EXIF_DATA =
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
export const SHOW_ZOOM_CONTROLS =
@ -215,14 +222,8 @@ export const SHOW_TAKEN_AT_TIME =
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
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';
export const SHOW_SIDEBAR_CAMERAS_FIRST =
process.env.NEXT_PUBLIC_CAMERAS_FIRST === '1';
// GRID
@ -304,22 +305,22 @@ export const APP_CONFIGURATION = {
// eslint-disable-next-line max-len
arePhotoCategoryOgImagesStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
areOriginalUploadsPreserved: PRESERVE_ORIGINAL_UPLOADS,
imageQuality: IMAGE_QUALITY,
hasImageQuality: Boolean(process.env.NEXT_PUBLIC_IMAGE_QUALITY),
imageQuality: IMAGE_QUALITY,
isBlurEnabled: BLUR_ENABLED,
// Visual
hasDefaultTheme: Boolean(process.env.NEXT_PUBLIC_DEFAULT_THEME),
defaultTheme: DEFAULT_THEME,
arePhotosMatted: MATTE_PHOTOS,
// Display
hasCategoryVisibility:
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),
categoryVisibility: CATEGORY_VISIBILITY,
showExifInfo: SHOW_EXIF_DATA,
showZoomControls: SHOW_ZOOM_CONTROLS,
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,
showFilmSimulations: SHOW_FILM_SIMULATIONS,
showRecipes: SHOW_RECIPES,
showRepoLink: SHOW_REPO_LINK,
showSidebarCamerasFirst: SHOW_SIDEBAR_CAMERAS_FIRST,
// Grid
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
gridAspectRatio: GRID_ASPECT_RATIO,

View File

@ -1,4 +1,5 @@
import { Photo, PhotoSetCategory } from '@/photo';
import { Photo } from '@/photo';
import { PhotoSetCategory } from '@/photo/set';
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from './config';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';

View File

@ -1,5 +1,5 @@
import { absolutePathForCamera } from '@/app/paths';
import { PhotoSetAttributes } from '../photo';
import { PhotoSetAttributes } from '../photo/set';
import ShareModal from '@/share/ShareModal';
import CameraOGTile from './CameraOGTile';
import { Camera } from '.';

View File

@ -2,7 +2,7 @@
import { blobToImage } from '@/utility/blob';
import { useRef, RefObject } from 'react';
import { CopyExif } from '@/lib/CopyExif';
import { CopyExif } from '@/utility/exif';
import exifr from 'exifr';
import { clsx } from 'clsx/lite';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';

View File

@ -23,6 +23,9 @@ import {
PATH_GRID_INFERRED,
PATH_ROOT,
PATH_SIGN_IN,
pathForCamera,
pathForFilmSimulation,
pathForFocalLength,
pathForPhoto,
pathForTag,
} from '../../app/paths';
@ -40,19 +43,25 @@ import { RiToolsFill } from 'react-icons/ri';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAction } from '@/auth/actions';
import { TbPhoto } from 'react-icons/tb';
import { TbChecklist, TbCone, TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
import PhotoSmall from '@/photo/PhotoSmall';
import { FaCheck } from 'react-icons/fa6';
import { Tags, addHiddenToTags, formatTag } from '@/tag';
import { addHiddenToTags, formatTag } from '@/tag';
import { FaTag } from 'react-icons/fa';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import CommandKItem from './CommandKItem';
import { GRID_HOMEPAGE_ENABLED } from '@/app/config';
import { CATEGORY_VISIBILITY, GRID_HOMEPAGE_ENABLED } from '@/app/config';
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot';
import { PhotoSetCategories } from '@/photo/set';
import { formatCameraText } from '@/camera';
import { IoMdCamera } from 'react-icons/io';
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { formatFocalLength } from '@/focal';
const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -70,7 +79,7 @@ type CommandKItem = {
action?: () => void | Promise<void>
}
export type CommandKSection = {
type CommandKSection = {
heading: string
accessory?: ReactNode
items: CommandKItem[]
@ -88,15 +97,16 @@ const renderToggle = (
export default function CommandKClient({
tags,
serverSections = [],
cameras,
recipes,
simulations,
focalLengths,
showDebugTools,
footer,
}: {
tags: Tags
serverSections?: CommandKSection[]
showDebugTools?: boolean
footer?: string
}) {
} & PhotoSetCategories) {
const pathname = usePathname();
const {
@ -234,7 +244,11 @@ export default function CommandKClient({
addHiddenToTags(tags, photosCountHidden)
, [tags, photosCountHidden]);
const SECTION_TAGS: CommandKSection = {
const categorySections: CommandKSection[] = useMemo(() =>
CATEGORY_VISIBILITY
.map(category => {
switch (category) {
case 'tags': return {
heading: 'Tags',
accessory: <FaTag
size={10}
@ -247,6 +261,56 @@ export default function CommandKClient({
path: pathForTag(tag),
})),
};
case 'cameras': return {
heading: 'Cameras',
accessory: <IoMdCamera />,
items: cameras.map(({ camera, count }) => ({
label: formatCameraText(camera),
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForCamera(camera),
})),
};
case 'recipes': return {
heading: 'Recipes',
accessory: <TbChecklist
size={15}
className="translate-x-[-1px]"
/>,
items: recipes.map(({ recipe, count }) => ({
label: recipe,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
})),
};
case 'films': return {
heading: 'Film Simulations',
accessory: <span className="w-3">
<PhotoFilmSimulationIcon className="translate-y-[0.5px]" />
</span>,
items: simulations.map(({ simulation, count }) => ({
label: labelForFilmSimulation(simulation).medium,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForFilmSimulation(simulation),
})),
};
case 'focal-lengths': return {
heading: 'Focal Lengths',
accessory: <TbCone
className="rotate-[270deg] text-[14px]"
/>,
items: focalLengths.map(({ focal, count }) => ({
label: formatFocalLength(focal)!,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForFocalLength(focal),
})),
};
}
})
.filter(Boolean) as CommandKSection[]
, [tagsIncludingHidden, cameras, recipes, simulations, focalLengths]);
const clientSections: CommandKSection[] = [{
heading: 'Theme',
@ -465,8 +529,7 @@ export default function CommandKClient({
{isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty>
{queriedSections
.concat(SECTION_TAGS)
.concat(serverSections)
.concat(categorySections)
.concat(sectionPages)
.concat(adminSection)
.concat(clientSections)

View File

@ -38,7 +38,10 @@ export default function TooltipPrimitive({
},
});
const classNameTrigger = clsx('link cursor-default', classNameTriggerProp);
const classNameTrigger = clsx(
'link cursor-default inline-block',
classNameTriggerProp,
);
return (
<Tooltip.Provider delayDuration={100}>

View File

@ -1,5 +1,5 @@
import { absolutePathForFocalLength } from '@/app/paths';
import { PhotoSetAttributes } from '../photo';
import { PhotoSetAttributes } from '../photo/set';
import ShareModal from '@/share/ShareModal';
import FocalLengthOGTile from './FocalLengthOGTile';
import { shareTextFocalLength } from '.';

View File

@ -1,36 +0,0 @@
export async function CopyExif(
src: Blob,
dest: Blob,
type = 'image/jpeg',
) {
const exif = await retrieveExif(src);
return new Blob([dest.slice(0, 2), exif, dest.slice(2)], { type });
};
const SOS = 0xffda;
const APP1 = 0xffe1;
const EXIF = 0x45786966;
const retrieveExif = (blob: Blob): Promise<Blob> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', e => {
const buffer = e.target!.result as ArrayBuffer;
const view = new DataView(buffer);
let offset = 0;
if (view.getUint16(offset) !== 0xffd8)
return reject('not a valid jpeg');
offset += 2;
while (true) {
const marker = view.getUint16(offset);
if (marker === SOS) break;
const size = view.getUint16(offset + 2);
if (marker === APP1 && view.getUint32(offset + 4) === EXIF)
return resolve(blob.slice(offset, offset + 2 + size));
offset += 2 + size;
}
return resolve(new Blob());
});
reader.readAsArrayBuffer(blob);
});

View File

@ -11,7 +11,8 @@ import {
import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner';
import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
import { Photo, PhotoSetCategory } from '.';
import { Photo } from '.';
import { PhotoSetCategory } from './set';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
import { GetPhotosOptions } from './db';

View File

@ -1,5 +1,6 @@
import AnimateItems from '@/components/AnimateItems';
import { Photo, PhotoDateRange, PhotoSetCategory } from '.';
import { Photo, PhotoDateRange } from '.';
import { PhotoSetCategory } from './set';
import PhotoLarge from './PhotoLarge';
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from './PhotoGrid';

View File

@ -1,6 +1,7 @@
'use client';
import { Photo, PhotoSetCategory } from '.';
import { Photo } from '.';
import { PhotoSetCategory } from './set';
import PhotoMedium from './PhotoMedium';
import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems';

View File

@ -15,7 +15,7 @@ import FavsTag from '../tag/FavsTag';
import { useAppState } from '@/state/AppState';
import { useMemo } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import { SHOW_SIDEBAR_CAMERAS_FIRST, SITE_ABOUT } from '@/app/config';
import { CATEGORY_VISIBILITY, SITE_ABOUT } from '@/app/config';
import {
htmlHasBrParagraphBreaks,
safelyParseFormattedHtml,
@ -184,11 +184,14 @@ export default function PhotoGridSidebar({
}}
/>]}
/>}
{SHOW_SIDEBAR_CAMERAS_FIRST
? <>{camerasContent}{tagsContent}</>
: <>{tagsContent}{camerasContent}</>}
{recipesContent}
{filmsContent}
{CATEGORY_VISIBILITY.map(category => {
switch (category) {
case 'cameras': return camerasContent;
case 'tags': return tagsContent;
case 'recipes': return recipesContent;
case 'films': return filmsContent;
}
})}
{photoStatsContent}
</div>
);

View File

@ -4,10 +4,10 @@ import { clsx } from 'clsx/lite';
import {
Photo,
PhotoDateRange,
PhotoSetCategory,
dateRangeForPhotos,
titleForPhoto,
} from '.';
import { PhotoSetCategory } from './set';
import ShareButton from '@/share/ShareButton';
import AnimateItems from '@/components/AnimateItems';
import { ReactNode } from 'react';

View File

@ -1,5 +1,6 @@
import { clsx } from 'clsx/lite';
import { Photo, PhotoSetCategory } from '.';
import { Photo } from '.';
import { PhotoSetCategory } from './set';
import PhotoGrid from './PhotoGrid';
import Link from 'next/link';

View File

@ -1,7 +1,8 @@
'use client';
import { ReactNode } from 'react';
import { Photo, PhotoSetCategory, titleForPhoto } from '@/photo';
import { Photo, titleForPhoto } from '@/photo';
import { PhotoSetCategory } from '@/photo/set';
import Link from 'next/link';
import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';

View File

@ -2,10 +2,10 @@
import {
Photo,
PhotoSetCategory,
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
} from '.';
import { PhotoSetCategory } from './set';
import ImageMedium from '@/components/image/ImageMedium';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/app/paths';

View File

@ -1,9 +1,9 @@
import {
Photo,
PhotoSetCategory,
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { PhotoSetCategory } from './set';
import { absolutePathForPhotoImage, pathForPhoto } from '@/app/paths';
import OGTile from '@/components/OGTile';

View File

@ -3,10 +3,10 @@
import { useEffect } from 'react';
import {
Photo,
PhotoSetCategory,
getNextPhoto,
getPreviousPhoto,
} from '@/photo';
import { PhotoSetCategory } from './set';
import PhotoLink from './PhotoLink';
import { useRouter } from 'next/navigation';
import { pathForPhoto } from '@/app/paths';

View File

@ -1,6 +1,7 @@
import PhotoOGTile from '@/photo/PhotoOGTile';
import { absolutePathForPhoto } from '@/app/paths';
import { Photo, PhotoSetCategory } from '.';
import { Photo } from '.';
import { PhotoSetCategory } from './set';
import ShareModal from '@/share/ShareModal';
export default function PhotoShareModal(

View File

@ -1,9 +1,9 @@
import {
Photo,
PhotoSetCategory,
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
} from '.';
import { PhotoSetCategory } from './set';
import ImageSmall from '@/components/image/ImageSmall';
import Link from 'next/link';
import { clsx } from 'clsx/lite';

View File

@ -1,6 +1,7 @@
import {
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueRecipesCached,
getUniqueTagsCached,
} from '@/photo/cache';
import {
@ -27,4 +28,5 @@ export const getPhotoSidebarDataCached = () => [
getUniqueTagsCached().then(sortTagsObject),
getUniqueCamerasCached(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
SHOW_RECIPES ? getUniqueRecipesCached() : [],
] as const;

View File

@ -1,6 +1,6 @@
import { PRIORITY_ORDER_ENABLED } from '@/app/config';
import { parameterize } from '@/utility/string';
import { PhotoSetCategory } from '..';
import { PhotoSetCategory } from '../set';
import { Camera } from '@/camera';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;

View File

@ -1,6 +1,4 @@
import { Camera } from '@/camera';
import { formatFocalLength } from '@/focal';
import { Lens } from '@/lens';
import { getNextImageUrlForRequest } from '@/platforms/next-image';
import { FilmSimulation } from '@/simulation';
import {
@ -109,21 +107,6 @@ export interface Photo extends Omit<PhotoDb, 'recipeData'> {
recipeData?: FujifilmRecipe
}
export interface PhotoSetCategory {
tag?: string
camera?: Camera
simulation?: FilmSimulation
recipe?: string
focal?: number
lens?: Lens // Unimplemented as a set
}
export interface PhotoSetAttributes {
photos: Photo[]
count?: number
dateRange?: PhotoDateRange
}
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
const photoDb = camelcaseKeys(
photoDbRaw as unknown as Record<string, unknown>,

64
src/photo/set.ts Normal file
View File

@ -0,0 +1,64 @@
import { Photo } from '.';
import { Camera, Cameras } from '@/camera';
import { PhotoDateRange } from '.';
import { FilmSimulation, FilmSimulations } from '@/simulation';
import { Lens } from '@/lens';
import { Tags } from '@/tag';
import { FocalLengths } from '@/focal';
import { Recipes } from '@/recipe';
const CATEGORY_KEYS = [
'tags',
'cameras',
'recipes',
'films',
'focal-lengths',
'lenses',
] as const;
type CategoryKey = (typeof CATEGORY_KEYS)[number];
type CategoryKeys = CategoryKey[];
export const DEFAULT_CATEGORY_KEYS: CategoryKeys = [
'tags',
'cameras',
'recipes',
'films',
];
export const getHiddenDefaultCategories = (keys: CategoryKeys): CategoryKeys =>
DEFAULT_CATEGORY_KEYS.filter(key => !keys.includes(key));
export interface PhotoSetCategory {
tag?: string
camera?: Camera
recipe?: string
simulation?: FilmSimulation
focal?: number
lens?: Lens // Unimplemented as a set
}
export interface PhotoSetCategories {
tags: Tags
cameras: Cameras
recipes: Recipes
simulations: FilmSimulations
focalLengths: FocalLengths
}
export interface PhotoSetAttributes {
photos: Photo[]
count?: number
dateRange?: PhotoDateRange
}
export const getOrderedCategoriesFromString = (
categories?: string,
): CategoryKeys =>
categories
? categories
.split(',')
.map(category => category.trim().toLocaleLowerCase() as CategoryKey)
.filter(category => CATEGORY_KEYS.includes(category))
: DEFAULT_CATEGORY_KEYS;

View File

@ -1,5 +1,5 @@
import { absolutePathForRecipe } from '@/app/paths';
import { PhotoSetAttributes } from '../photo';
import { PhotoSetAttributes } from '../photo/set';
import ShareModal from '@/share/ShareModal';
import { shareTextForRecipe } from '.';
import RecipeOGTile from './RecipeOGTile';

View File

@ -1,4 +1,5 @@
import { Photo, PhotoSetAttributes, PhotoSetCategory } from '@/photo';
import { Photo } from '@/photo';
import { PhotoSetAttributes, PhotoSetCategory } from '@/photo/set';
import {
absolutePathForCameraImage,
absolutePathForFilmSimulationImage,

View File

@ -1,5 +1,5 @@
import { absolutePathForFilmSimulation } from '@/app/paths';
import { PhotoSetAttributes } from '../photo';
import { PhotoSetAttributes } from '../photo/set';
import ShareModal from '@/share/ShareModal';
import FilmSimulationOGTile from './FilmSimulationOGTile';
import { FilmSimulation, shareTextForFilmSimulation } from '.';

View File

@ -1,5 +1,5 @@
import { absolutePathForTag } from '@/app/paths';
import { PhotoSetAttributes } from '../photo';
import { PhotoSetAttributes } from '../photo/set';
import ShareModal from '@/share/ShareModal';
import TagOGTile from './TagOGTile';
import { shareTextForTag } from '.';

View File

@ -86,3 +86,41 @@ export const formatExposureCompensation = (exposureCompensation?: number) => {
return undefined;
}
};
const SOS = 0xffda;
const APP1 = 0xffe1;
const EXIF = 0x45786966;
const retrieveExif = (blob: Blob): Promise<Blob> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', e => {
const buffer = e.target!.result as ArrayBuffer;
const view = new DataView(buffer);
let offset = 0;
if (view.getUint16(offset) !== 0xffd8)
return reject('not a valid jpeg');
offset += 2;
while (true) {
const marker = view.getUint16(offset);
if (marker === SOS) break;
const size = view.getUint16(offset + 2);
if (marker === APP1 && view.getUint32(offset + 4) === EXIF)
return resolve(blob.slice(offset, offset + 2 + size));
offset += 2 + size;
}
return resolve(new Blob());
});
reader.readAsArrayBuffer(blob);
});
export const CopyExif = async (
src: Blob,
dest: Blob,
type = 'image/jpeg',
) => {
const exif = await retrieveExif(src);
return new Blob([dest.slice(0, 2), exif, dest.slice(2)], { type });
};