diff --git a/README.md b/README.md index 6cec6f2e..1b64cc22 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ _⚠️ READ BEFORE PROCEEDING_ 2. Add rate limiting (_recommended_) - As an additional precaution, create an Upstash Redis store from the storage tab of the Vercel dashboard and link it to your project in order to enable rate limiting—no further configuration necessary 3. Configure auto-generated fields (optional) - - Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title, semantic` + - Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title,semantic` - Accepted values: - `all` - `title` (default) @@ -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. diff --git a/__tests__/set.test.ts b/__tests__/set.test.ts new file mode 100644 index 00000000..36a0131b --- /dev/null +++ b/__tests__/set.test.ts @@ -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', + ]); + }); +}); diff --git a/package.json b/package.json index 7e6762ca..51373330 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383204c6..1f810c45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index 283f36c5..b94d0e41 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -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, @@ -375,7 +377,7 @@ export default function AdminAppConfigurationClient({ @@ -383,7 +385,7 @@ export default function AdminAppConfigurationClient({ uploading photos. Accepted values: title, caption, tags, description, all, or none {' '} - (default: {'"title, tags, semantic"'}): + (default: {'"title,tags,semantic"'}): {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} @@ -517,25 +519,6 @@ export default function AdminAppConfigurationClient({ X (formerly Twitter) button from share modal: {renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])} - - Set environment variable to {'"1"'} to prevent - simulations showing up in /grid sidebar and - CMD-K results: - {renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])} - - - Set environment variable to {'"1"'} to prevent - Fujifilm recipe button showing up in photo meta: - {renderEnvVars(['NEXT_PUBLIC_HIDE_RECIPES'])} - - Set environment variable to {'"1"'} to show cameras - above tags in grid sidebar: - {renderEnvVars(['NEXT_PUBLIC_CAMERAS_FIRST'])} + {categoryVisibility.map((category, index) => + + {renderSubStatus( + 'checked', + <> + {index + 1} + {'.'} + {capitalize(category)} + , + )} + )} + {getHiddenDefaultCategories(categoryVisibility) + .map((category, index) => + + {renderSubStatus( + 'optional', + + {categoryVisibility.length + index + 1} + {'.'} + {capitalize(category)} + , + )} + )} + 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'])} 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, diff --git a/src/admin/insights/AdminAppInsightsClient.tsx b/src/admin/insights/AdminAppInsightsClient.tsx index 973b4285..ee76bcec 100644 --- a/src/admin/insights/AdminAppInsightsClient.tsx +++ b/src/admin/insights/AdminAppInsightsClient.tsx @@ -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 + Could not analyze source code } />} @@ -357,8 +359,8 @@ export default function AdminAppInsightsClient({ showing cameras first in the sidebar by setting {' '} } @@ -411,30 +413,52 @@ export default function AdminAppInsightsClient({ {photosCountHidden > 0 && ` (${photosCountHidden} hidden)`} } /> - } - content={pluralize(tagsCount, 'tag')} - /> - } - content={pluralize(camerasCount, 'camera')} - /> - {filmSimulationsCount > 0 && - - - } - content={pluralize(filmSimulationsCount, 'film simulation')} - />} + {CATEGORY_VISIBILITY.map(category => { + switch (category) { + case 'tags': + return } + content={pluralize(tagsCount, 'tag')} + />; + case 'cameras': + return } + content={pluralize(camerasCount, 'camera')} + />; + case 'recipes': + if (recipesCount > 0) { + return } + content={pluralize(recipesCount, 'recipe')} + />; + } + case 'films': + if (filmSimulationsCount > 0) { + return + + } + content={pluralize(filmSimulationsCount, 'film simulation')} + />; + } + } + })} } content={pluralize(focalLengthsCount, 'focal length')} diff --git a/src/admin/insights/index.ts b/src/admin/insights/index.ts index 2a23fffd..216f0c43 100644 --- a/src/admin/insights/index.ts +++ b/src/admin/insights/index.ts @@ -50,6 +50,7 @@ export interface PhotoStats { photosCountOutdated: number tagsCount: number camerasCount: number + recipesCount: number filmSimulationsCount: number focalLengthsCount: number dateRange?: PhotoDateRange diff --git a/src/app/CommandK.tsx b/src/app/CommandK.tsx index 7d239adc..341765a5 100644 --- a/src/app/CommandK.tsx +++ b/src/app/CommandK.tsx @@ -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: , - 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: - - , - 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: , - items: focalLengths.map(({ focal, count }) => ({ - label: formatFocalLength(focal)!, - annotation: formatCount(count), - annotationAria: formatCountDescriptive(count), - path: pathForFocalLength(focal), - })), - }; - return ; diff --git a/src/app/config.ts b/src/app/config.ts index 0fca306c..a2569bf8 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -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, diff --git a/src/app/paths.ts b/src/app/paths.ts index a2247184..8490f53b 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -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'; diff --git a/src/camera/CameraShareModal.tsx b/src/camera/CameraShareModal.tsx index d731e5a5..d138669b 100644 --- a/src/camera/CameraShareModal.tsx +++ b/src/camera/CameraShareModal.tsx @@ -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 '.'; diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index a1e716e4..d9e4702b 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -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'; diff --git a/src/components/cmdk/CommandKClient.tsx b/src/components/cmdk/CommandKClient.tsx index e249b48f..01782d4e 100644 --- a/src/components/cmdk/CommandKClient.tsx +++ b/src/components/cmdk/CommandKClient.tsx @@ -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 } -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,19 +244,73 @@ export default function CommandKClient({ addHiddenToTags(tags, photosCountHidden) , [tags, photosCountHidden]); - const SECTION_TAGS: CommandKSection = { - heading: 'Tags', - accessory: , - items: tagsIncludingHidden.map(({ tag, count }) => ({ - label: formatTag(tag), - annotation: formatCount(count), - annotationAria: formatCountDescriptive(count), - path: pathForTag(tag), - })), - }; + const categorySections: CommandKSection[] = useMemo(() => + CATEGORY_VISIBILITY + .map(category => { + switch (category) { + case 'tags': return { + heading: 'Tags', + accessory: , + items: tagsIncludingHidden.map(({ tag, count }) => ({ + label: formatTag(tag), + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + path: pathForTag(tag), + })), + }; + case 'cameras': return { + heading: 'Cameras', + accessory: , + items: cameras.map(({ camera, count }) => ({ + label: formatCameraText(camera), + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + path: pathForCamera(camera), + })), + }; + case 'recipes': return { + heading: 'Recipes', + accessory: , + items: recipes.map(({ recipe, count }) => ({ + label: recipe, + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + })), + }; + case 'films': return { + heading: 'Film Simulations', + accessory: + + , + 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: , + 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'} {queriedSections - .concat(SECTION_TAGS) - .concat(serverSections) + .concat(categorySections) .concat(sectionPages) .concat(adminSection) .concat(clientSections) diff --git a/src/components/primitives/TooltipPrimitive.tsx b/src/components/primitives/TooltipPrimitive.tsx index c63401d1..6a3de66a 100644 --- a/src/components/primitives/TooltipPrimitive.tsx +++ b/src/components/primitives/TooltipPrimitive.tsx @@ -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 ( diff --git a/src/focal/FocalLengthShareModal.tsx b/src/focal/FocalLengthShareModal.tsx index b770956b..bf8886df 100644 --- a/src/focal/FocalLengthShareModal.tsx +++ b/src/focal/FocalLengthShareModal.tsx @@ -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 '.'; diff --git a/src/lib/CopyExif.ts b/src/lib/CopyExif.ts deleted file mode 100644 index edb8b4b4..00000000 --- a/src/lib/CopyExif.ts +++ /dev/null @@ -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 => - 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); - }); diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index 77adb2f4..96bbab98 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -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'; diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index 777f771c..91734395 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -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'; diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index b65a2eb9..704cfb9a 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -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'; diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index f7668b2e..8b8686ce 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -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} ); diff --git a/src/photo/PhotoHeader.tsx b/src/photo/PhotoHeader.tsx index 2af025b4..41880c49 100644 --- a/src/photo/PhotoHeader.tsx +++ b/src/photo/PhotoHeader.tsx @@ -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'; diff --git a/src/photo/PhotoLightbox.tsx b/src/photo/PhotoLightbox.tsx index 7566f7f8..d5ebe63d 100644 --- a/src/photo/PhotoLightbox.tsx +++ b/src/photo/PhotoLightbox.tsx @@ -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'; diff --git a/src/photo/PhotoLink.tsx b/src/photo/PhotoLink.tsx index 13954883..aa5d23a5 100644 --- a/src/photo/PhotoLink.tsx +++ b/src/photo/PhotoLink.tsx @@ -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'; diff --git a/src/photo/PhotoMedium.tsx b/src/photo/PhotoMedium.tsx index c15304da..e9f5554e 100644 --- a/src/photo/PhotoMedium.tsx +++ b/src/photo/PhotoMedium.tsx @@ -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'; diff --git a/src/photo/PhotoOGTile.tsx b/src/photo/PhotoOGTile.tsx index eacd838e..4ad6c627 100644 --- a/src/photo/PhotoOGTile.tsx +++ b/src/photo/PhotoOGTile.tsx @@ -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'; diff --git a/src/photo/PhotoPrevNext.tsx b/src/photo/PhotoPrevNext.tsx index 5d9cf5fa..f8a943b2 100644 --- a/src/photo/PhotoPrevNext.tsx +++ b/src/photo/PhotoPrevNext.tsx @@ -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'; diff --git a/src/photo/PhotoShareModal.tsx b/src/photo/PhotoShareModal.tsx index 31f6a09e..1a376997 100644 --- a/src/photo/PhotoShareModal.tsx +++ b/src/photo/PhotoShareModal.tsx @@ -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( diff --git a/src/photo/PhotoSmall.tsx b/src/photo/PhotoSmall.tsx index 1a4658a0..b80d8324 100644 --- a/src/photo/PhotoSmall.tsx +++ b/src/photo/PhotoSmall.tsx @@ -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'; diff --git a/src/photo/data.ts b/src/photo/data.ts index c33a3a83..986d9e91 100644 --- a/src/photo/data.ts +++ b/src/photo/data.ts @@ -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; diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index 5a670ed2..8b299eb2 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -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; diff --git a/src/photo/index.ts b/src/photo/index.ts index 70defbbf..170a7e08 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -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 { 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, diff --git a/src/photo/set.ts b/src/photo/set.ts new file mode 100644 index 00000000..34484b87 --- /dev/null +++ b/src/photo/set.ts @@ -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; diff --git a/src/recipe/RecipeShareModal.tsx b/src/recipe/RecipeShareModal.tsx index cb62fb82..3c7c6bfb 100644 --- a/src/recipe/RecipeShareModal.tsx +++ b/src/recipe/RecipeShareModal.tsx @@ -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'; diff --git a/src/share/index.ts b/src/share/index.ts index 22cc0f47..5929814d 100644 --- a/src/share/index.ts +++ b/src/share/index.ts @@ -1,4 +1,5 @@ -import { Photo, PhotoSetAttributes, PhotoSetCategory } from '@/photo'; +import { Photo } from '@/photo'; +import { PhotoSetAttributes, PhotoSetCategory } from '@/photo/set'; import { absolutePathForCameraImage, absolutePathForFilmSimulationImage, diff --git a/src/simulation/FilmSimulationShareModal.tsx b/src/simulation/FilmSimulationShareModal.tsx index 6a5929fa..f820ed27 100644 --- a/src/simulation/FilmSimulationShareModal.tsx +++ b/src/simulation/FilmSimulationShareModal.tsx @@ -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 '.'; diff --git a/src/tag/TagShareModal.tsx b/src/tag/TagShareModal.tsx index 1a861fde..35f1d1d0 100644 --- a/src/tag/TagShareModal.tsx +++ b/src/tag/TagShareModal.tsx @@ -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 '.'; diff --git a/src/utility/exif.ts b/src/utility/exif.ts index f9c0e2f8..f732f53e 100644 --- a/src/utility/exif.ts +++ b/src/utility/exif.ts @@ -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 => + 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 }); +}; +