About Page (#378)
* Highlight /about in nav * Refine full frame icon * Add timestamp to /about * Add /about to cmdk menu * Enrich /about content * Make /about categories responsive * Enlarge app nav buttons * Add /about richer categories * Widen main nav buttons * Add more /about category content * Catch db errors in /about * Update key /about image * Add /about avatar * Add jest TextEncoder polyfill * Refactor sidebar text configuration * Show /about hero photo meta * Hoist about content to server page * Hide admin email on small screens * Add basic about page form * Finalize basic /about upsert functionality * Make /about/edit safe for blank templates * Add configuration to hide /about page * Add default /about title text * Add interactive photos to /about edit form * Apply final /about i18n * Ensure /about static optimization * Add CTA for admins to add /about descriptions * Add convenience for accepting full photo urls * Add photo placeholder icon * Show /about empty state when there are no photos * Hide sort control when in app empty state
This commit is contained in:
parent
e10589b048
commit
5970bfb850
@ -71,7 +71,7 @@ See FAQ for [limitations of local development](#can-i-work-locally-without-acces
|
|||||||
- `NEXT_PUBLIC_META_DESCRIPTION` (seen in search results)
|
- `NEXT_PUBLIC_META_DESCRIPTION` (seen in search results)
|
||||||
- `NEXT_PUBLIC_NAV_TITLE` (seen in top-right navigation, defaults to domain when not configured)
|
- `NEXT_PUBLIC_NAV_TITLE` (seen in top-right navigation, defaults to domain when not configured)
|
||||||
- `NEXT_PUBLIC_NAV_CAPTION` (seen in top-right navigation, beneath title)
|
- `NEXT_PUBLIC_NAV_CAPTION` (seen in top-right navigation, beneath title)
|
||||||
- `NEXT_PUBLIC_PAGE_ABOUT` (seen in grid sidebar—accepts rich formatting tags: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
|
- `NEXT_PUBLIC_SIDEBAR_TEXT` (seen in grid sidebar—accepts rich formatting tags: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
|
||||||
- `NEXT_PUBLIC_DOMAIN_SHARE` (seen in share modals where a shorter url may be desirable)
|
- `NEXT_PUBLIC_DOMAIN_SHARE` (seen in share modals where a shorter url may be desirable)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
@ -160,6 +160,7 @@ Create Upstash Redis store from storage tab of Vercel dashboard and link to your
|
|||||||
|
|
||||||
|
|
||||||
### Display
|
### Display
|
||||||
|
- `NEXT_PUBLIC_HIDE_ABOUT_PAGE = 1` hides `/about` page
|
||||||
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
|
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
|
||||||
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
|
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
|
||||||
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
|
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
isPathTag,
|
isPathTag,
|
||||||
isPathTagPhoto,
|
isPathTagPhoto,
|
||||||
PATH_ADMIN,
|
PATH_ADMIN,
|
||||||
|
PATH_ADMIN_ABOUT_EDIT,
|
||||||
PATH_ADMIN_PHOTOS,
|
PATH_ADMIN_PHOTOS,
|
||||||
PATH_FULL,
|
PATH_FULL,
|
||||||
PATH_GRID,
|
PATH_GRID,
|
||||||
@ -91,11 +92,12 @@ describe('Paths', () => {
|
|||||||
// Private
|
// Private
|
||||||
expect(isPathProtected(PATH_ADMIN)).toBe(true);
|
expect(isPathProtected(PATH_ADMIN)).toBe(true);
|
||||||
expect(isPathProtected(PATH_ADMIN_PHOTOS)).toBe(true);
|
expect(isPathProtected(PATH_ADMIN_PHOTOS)).toBe(true);
|
||||||
|
expect(isPathProtected(PATH_TAG_PRIVATE)).toBe(true);
|
||||||
|
expect(isPathProtected(PATH_TAG_PRIVATE_PHOTO)).toBe(true);
|
||||||
|
expect(isPathProtected(PATH_ADMIN_ABOUT_EDIT)).toBe(true);
|
||||||
expect(isPathProtected(PATH_OG)).toBe(true);
|
expect(isPathProtected(PATH_OG)).toBe(true);
|
||||||
expect(isPathProtected(PATH_OG_ALL)).toBe(true);
|
expect(isPathProtected(PATH_OG_ALL)).toBe(true);
|
||||||
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
|
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
|
||||||
expect(isPathProtected(PATH_TAG_PRIVATE)).toBe(true);
|
|
||||||
expect(isPathProtected(PATH_TAG_PRIVATE_PHOTO)).toBe(true);
|
|
||||||
});
|
});
|
||||||
it('can be classified', () => {
|
it('can be classified', () => {
|
||||||
// Positive
|
// Positive
|
||||||
|
|||||||
27
app/about/edit/page.tsx
Normal file
27
app/about/edit/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import AdminAboutEditPage from '@/about/AdminAboutEditPage';
|
||||||
|
import { getAbout } from '@/about/query';
|
||||||
|
import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
|
||||||
|
import { getPhotoNoStore } from '@/photo/cache';
|
||||||
|
|
||||||
|
export default async function AboutEditPage() {
|
||||||
|
const about = await getAbout().catch(() => undefined);
|
||||||
|
|
||||||
|
const photoAvatar = about?.photoIdAvatar
|
||||||
|
? await getPhotoNoStore(about?.photoIdAvatar ?? '', true)
|
||||||
|
.catch(() => undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const photoHero = about?.photoIdHero
|
||||||
|
? await getPhotoNoStore(about?.photoIdHero ?? '', true)
|
||||||
|
.catch(() => undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminAboutEditPage {...{
|
||||||
|
about,
|
||||||
|
photoAvatar,
|
||||||
|
photoHero,
|
||||||
|
shouldResizeImages: !PRESERVE_ORIGINAL_UPLOADS,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
106
app/about/page.tsx
Normal file
106
app/about/page.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import AboutPageClient from '@/about/AboutPageClient';
|
||||||
|
import { getAboutCached } from '@/about/cache';
|
||||||
|
import { SHOW_ABOUT_PAGE } from '@/app/config';
|
||||||
|
import { PATH_ROOT } from '@/app/path';
|
||||||
|
import { getDataForCategoriesCached } from '@/category/cache';
|
||||||
|
import {
|
||||||
|
getLastModifiedForCategories,
|
||||||
|
NULL_CATEGORY_DATA,
|
||||||
|
} from '@/category/data';
|
||||||
|
import {
|
||||||
|
getPhotoCached,
|
||||||
|
getPhotosCached,
|
||||||
|
getPhotosMetaCached,
|
||||||
|
} from '@/photo/cache';
|
||||||
|
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||||
|
import { getAllPhotoIdsWithUpdatedAt } from '@/photo/query';
|
||||||
|
import { TAG_FAVS } from '@/tag';
|
||||||
|
import { safelyParseFormattedHtml } from '@/utility/html';
|
||||||
|
import { max } from 'date-fns';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export default async function AboutPage() {
|
||||||
|
if (!SHOW_ABOUT_PAGE) { redirect(PATH_ROOT); }
|
||||||
|
|
||||||
|
const [
|
||||||
|
{
|
||||||
|
about,
|
||||||
|
photoAvatar,
|
||||||
|
photoHero,
|
||||||
|
},
|
||||||
|
photosMeta,
|
||||||
|
photos,
|
||||||
|
categories,
|
||||||
|
] = await Promise.all([
|
||||||
|
getAboutCached()
|
||||||
|
.then(async about => {
|
||||||
|
const photoAvatar = await (about?.photoIdAvatar
|
||||||
|
? getPhotoCached(about?.photoIdAvatar ?? '', true)
|
||||||
|
: undefined);
|
||||||
|
const photoHero = await (about?.photoIdHero
|
||||||
|
? getPhotoCached(about?.photoIdHero ?? '', true)
|
||||||
|
// Fall back to favorite photos if no hero photo is set
|
||||||
|
: getPhotosCached({ tag: TAG_FAVS, limit: 1 })
|
||||||
|
.then(photos => photos.length > 0
|
||||||
|
? photos[0]
|
||||||
|
// Fall back to oldest photo if no favorite photos exist
|
||||||
|
: getPhotosCached({ limit: 1, sortBy: 'takenAtAsc' })
|
||||||
|
.then(photos => photos[0])));
|
||||||
|
return {
|
||||||
|
about,
|
||||||
|
photoAvatar,
|
||||||
|
photoHero,
|
||||||
|
};
|
||||||
|
}).catch(() => ({
|
||||||
|
about: undefined,
|
||||||
|
photoAvatar: undefined,
|
||||||
|
photoHero: undefined,
|
||||||
|
})),
|
||||||
|
getPhotosMetaCached().catch(() => {}),
|
||||||
|
getAllPhotoIdsWithUpdatedAt().catch(() => []),
|
||||||
|
getDataForCategoriesCached().catch(() => (NULL_CATEGORY_DATA)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const description = about?.description
|
||||||
|
? <div dangerouslySetInnerHTML={{
|
||||||
|
__html: safelyParseFormattedHtml(about.description),
|
||||||
|
}} />
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const {
|
||||||
|
cameras,
|
||||||
|
lenses,
|
||||||
|
albums,
|
||||||
|
tags,
|
||||||
|
recipes,
|
||||||
|
films,
|
||||||
|
} = categories;
|
||||||
|
|
||||||
|
const lastModifiedSite = max([
|
||||||
|
getLastModifiedForCategories(categories, photos),
|
||||||
|
about?.updatedAt,
|
||||||
|
].filter(date => date instanceof Date));
|
||||||
|
|
||||||
|
return (
|
||||||
|
(photosMeta?.count ?? 0) > 0
|
||||||
|
? <AboutPageClient
|
||||||
|
title={about?.title}
|
||||||
|
subhead={about?.subhead}
|
||||||
|
description={description}
|
||||||
|
photosCount={photosMeta?.count}
|
||||||
|
photosOldest={photosMeta?.dateRange?.start}
|
||||||
|
photoAvatar={photoAvatar}
|
||||||
|
photoHero={photoHero}
|
||||||
|
camera={cameras[0]?.camera}
|
||||||
|
lens={lenses[0]?.lens}
|
||||||
|
recipe={recipes[0]?.recipe}
|
||||||
|
film={films[0]?.film}
|
||||||
|
album={albums[0]?.album}
|
||||||
|
tag={tags.filter(({ tag }) => tag !== TAG_FAVS)[0]?.tag}
|
||||||
|
lastUpdated={lastModifiedSite}
|
||||||
|
/>
|
||||||
|
: <PhotosEmptyState />
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,10 @@ import {
|
|||||||
import { isTagFavs } from '@/tag';
|
import { isTagFavs } from '@/tag';
|
||||||
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
||||||
import { getAllPhotoIdsWithUpdatedAt } from '@/photo/query';
|
import { getAllPhotoIdsWithUpdatedAt } from '@/photo/query';
|
||||||
|
import {
|
||||||
|
getLastModifiedForCategories,
|
||||||
|
NULL_CATEGORY_DATA,
|
||||||
|
} from '@/category/data';
|
||||||
|
|
||||||
// Cache for 24 hours
|
// Cache for 24 hours
|
||||||
export const revalidate = 86_400;
|
export const revalidate = 86_400;
|
||||||
@ -29,47 +33,29 @@ const PRIORITY_PHOTO = 0.5;
|
|||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const [
|
const [
|
||||||
{
|
categories,
|
||||||
recents,
|
|
||||||
years,
|
|
||||||
cameras,
|
|
||||||
lenses,
|
|
||||||
albums,
|
|
||||||
tags,
|
|
||||||
recipes,
|
|
||||||
films,
|
|
||||||
focalLengths,
|
|
||||||
},
|
|
||||||
photos,
|
photos,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getDataForCategoriesCached().catch(() => ({
|
getDataForCategoriesCached().catch(() => NULL_CATEGORY_DATA),
|
||||||
recents: [],
|
|
||||||
years: [],
|
|
||||||
cameras: [],
|
|
||||||
lenses: [],
|
|
||||||
albums: [],
|
|
||||||
tags: [],
|
|
||||||
recipes: [],
|
|
||||||
films: [],
|
|
||||||
focalLengths: [],
|
|
||||||
})),
|
|
||||||
getAllPhotoIdsWithUpdatedAt().catch(() => []),
|
getAllPhotoIdsWithUpdatedAt().catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lastModifiedSite = [
|
const {
|
||||||
...recents.map(({ lastModified }) => lastModified),
|
recents,
|
||||||
...years.map(({ lastModified }) => lastModified),
|
years,
|
||||||
...cameras.map(({ lastModified }) => lastModified),
|
cameras,
|
||||||
...lenses.map(({ lastModified }) => lastModified),
|
lenses,
|
||||||
...albums.map(({ lastModified }) => lastModified),
|
albums,
|
||||||
...tags.map(({ lastModified }) => lastModified),
|
tags,
|
||||||
...recipes.map(({ lastModified }) => lastModified),
|
recipes,
|
||||||
...films.map(({ lastModified }) => lastModified),
|
films,
|
||||||
...focalLengths.map(({ lastModified }) => lastModified),
|
focalLengths,
|
||||||
...photos.map(({ updatedAt }) => updatedAt),
|
} = categories;
|
||||||
]
|
|
||||||
.filter(date => date instanceof Date)
|
const lastModifiedSite = getLastModifiedForCategories(
|
||||||
.sort((a, b) => b.getTime() - a.getTime())[0];
|
categories,
|
||||||
|
photos,
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Homepage
|
// Homepage
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
/* eslint-disable max-len */
|
|
||||||
import type { Config } from 'jest';
|
import type { Config } from 'jest';
|
||||||
import nextJest from 'next/jest.js';
|
import nextJest from 'next/jest.js';
|
||||||
|
|
||||||
const createJestConfig = nextJest({
|
const createJestConfig = nextJest({
|
||||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
// Provide the path to your Next.js app to load next.config.js and
|
||||||
|
// .env files in your test environment
|
||||||
dir: './',
|
dir: './',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -14,5 +14,6 @@ const config: Config = {
|
|||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
// createJestConfig is exported this way to ensure that
|
||||||
|
// next/jest can load the Next.js config which is async
|
||||||
export default createJestConfig(config);
|
export default createJestConfig(config);
|
||||||
|
|||||||
@ -1 +1,4 @@
|
|||||||
import 'cross-fetch/polyfill';
|
import 'cross-fetch/polyfill';
|
||||||
|
import { TextEncoder, TextDecoder } from 'util';
|
||||||
|
|
||||||
|
Object.assign(global, { TextDecoder, TextEncoder });
|
||||||
|
|||||||
18
package.json
18
package.json
@ -8,12 +8,12 @@
|
|||||||
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
|
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
|
||||||
"analyze": "ANALYZE=true next build --webpack"
|
"analyze": "ANALYZE=true next build --webpack"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.30.1",
|
"packageManager": "pnpm@10.30.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^3.0.30",
|
"@ai-sdk/openai": "^3.0.34",
|
||||||
"@ai-sdk/rsc": "^2.0.97",
|
"@ai-sdk/rsc": "^2.0.100",
|
||||||
"@aws-sdk/client-s3": "3.995.0",
|
"@aws-sdk/client-s3": "3.998.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.995.0",
|
"@aws-sdk/s3-request-presigner": "3.998.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@ -23,7 +23,7 @@
|
|||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"@vercel/blob": "^2.3.0",
|
"@vercel/blob": "^2.3.0",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
"@vercel/speed-insights": "^1.3.1",
|
||||||
"ai": "^6.0.97",
|
"ai": "^6.0.100",
|
||||||
"camelcase-keys": "^10.0.2",
|
"camelcase-keys": "^10.0.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@ -40,7 +40,7 @@
|
|||||||
"next-auth": "5.0.0-beta.30",
|
"next-auth": "5.0.0-beta.30",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"ol": "^10.8.0",
|
"ol": "^10.8.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.19.0",
|
||||||
"piexifjs": "^1.0.6",
|
"piexifjs": "^1.0.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@ -59,7 +59,7 @@
|
|||||||
"@next/bundle-analyzer": "16.1.6",
|
"@next/bundle-analyzer": "16.1.6",
|
||||||
"@next/eslint-plugin-next": "16.1.6",
|
"@next/eslint-plugin-next": "16.1.6",
|
||||||
"@stylistic/eslint-plugin": "^5.9.0",
|
"@stylistic/eslint-plugin": "^5.9.0",
|
||||||
"@tailwindcss/postcss": "^4.2.0",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@ -78,7 +78,7 @@
|
|||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"tailwindcss": "4.2.0",
|
"tailwindcss": "4.2.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
1543
pnpm-lock.yaml
generated
1543
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
proxy.ts
3
proxy.ts
@ -47,11 +47,12 @@ export const config = {
|
|||||||
// - /favicon.ico + /favicons/*
|
// - /favicon.ico + /favicons/*
|
||||||
// - /grid
|
// - /grid
|
||||||
// - /full
|
// - /full
|
||||||
|
// - /about
|
||||||
// - / (root)
|
// - / (root)
|
||||||
// - /home-image
|
// - /home-image
|
||||||
// - /template-image
|
// - /template-image
|
||||||
// - /template-image-tight
|
// - /template-image-tight
|
||||||
// - /template-url
|
// - /template-url
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
matcher: ['/((?!api$|api/auth|_next/static|_next/image|favicon.ico$|favicons/|grid$|full$|home-image$|template-image$|template-image-tight$|template-url$|$).*)'],
|
matcher: ['/((?!api$|api/auth|_next/static|_next/image|favicon.ico$|favicons/|grid$|full$|about$|home-image$|template-image$|template-image-tight$|template-url$|$).*)'],
|
||||||
};
|
};
|
||||||
|
|||||||
213
src/about/AboutPageClient.tsx
Normal file
213
src/about/AboutPageClient.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import PhotoAlbum from '@/album/PhotoAlbum';
|
||||||
|
import { useAppState } from '@/app/AppState';
|
||||||
|
import PhotoCamera from '@/camera/PhotoCamera';
|
||||||
|
import AnimateItems from '@/components/AnimateItems';
|
||||||
|
import AppGrid from '@/components/AppGrid';
|
||||||
|
import PhotoFilm from '@/film/PhotoFilm';
|
||||||
|
import PhotoLens from '@/lens/PhotoLens';
|
||||||
|
import { Photo } from '@/photo';
|
||||||
|
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
||||||
|
import PhotoTag from '@/tag/PhotoTag';
|
||||||
|
import clsx from 'clsx/lite';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import AdminAboutMenu from './AdminAboutMenu';
|
||||||
|
import PhotoLarge from '@/photo/PhotoLarge';
|
||||||
|
import { ReactNode, useMemo } from 'react';
|
||||||
|
import { Camera } from '@/camera';
|
||||||
|
import { Lens } from '@/lens';
|
||||||
|
import { Album } from '@/album';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import PhotoAvatar from '@/photo/PhotoAvatar';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PATH_ADMIN_ABOUT_EDIT } from '@/app/path';
|
||||||
|
import { LuCirclePlus, LuUser } from 'react-icons/lu';
|
||||||
|
import AdminEmptyState from '@/admin/AdminEmptyState';
|
||||||
|
|
||||||
|
export default function AboutPageClient({
|
||||||
|
title,
|
||||||
|
subhead,
|
||||||
|
description,
|
||||||
|
photosCount = 0,
|
||||||
|
photosOldest,
|
||||||
|
photoAvatar,
|
||||||
|
photoHero,
|
||||||
|
camera,
|
||||||
|
lens,
|
||||||
|
recipe,
|
||||||
|
film,
|
||||||
|
album,
|
||||||
|
tag,
|
||||||
|
lastUpdated,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
subhead?: string
|
||||||
|
description?: ReactNode
|
||||||
|
photosCount?: number
|
||||||
|
photosOldest?: string
|
||||||
|
photoAvatar?: Photo
|
||||||
|
photoHero?: Photo
|
||||||
|
camera?: Camera
|
||||||
|
lens?: Lens
|
||||||
|
recipe?: string
|
||||||
|
film?: string
|
||||||
|
album?: Album
|
||||||
|
tag?: string
|
||||||
|
lastUpdated?: Date
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isUserSignedIn,
|
||||||
|
} = useAppState();
|
||||||
|
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
|
const renderItem = (label: string, content?: ReactNode) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="border-t border-medium pt-1 space-y-px"
|
||||||
|
>
|
||||||
|
<div className="text-[13px] uppercase tracking-wide text-dim truncate">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-[16px] truncate">
|
||||||
|
{content || '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = useMemo(() => [
|
||||||
|
renderItem(
|
||||||
|
appText.about.photoCount,
|
||||||
|
photosCount.toString().padStart(4, '0'),
|
||||||
|
),
|
||||||
|
renderItem(
|
||||||
|
appText.about.firstPhoto,
|
||||||
|
photosOldest?.slice(0, 10),
|
||||||
|
),
|
||||||
|
camera && renderItem(
|
||||||
|
appText.about.topCamera,
|
||||||
|
<PhotoCamera
|
||||||
|
camera={camera}
|
||||||
|
type="text-only"
|
||||||
|
contrast="high"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
lens && renderItem(
|
||||||
|
appText.about.topLens,
|
||||||
|
<PhotoLens
|
||||||
|
lens={lens}
|
||||||
|
type="text-only"
|
||||||
|
contrast="high"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
recipe && renderItem(
|
||||||
|
appText.about.topRecipe,
|
||||||
|
<PhotoRecipe
|
||||||
|
recipe={recipe}
|
||||||
|
type="text-only"
|
||||||
|
contrast="high"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
film && renderItem(
|
||||||
|
appText.about.topFilm,
|
||||||
|
<PhotoFilm
|
||||||
|
film={film}
|
||||||
|
type="text-only"
|
||||||
|
contrast="high"
|
||||||
|
badged={false}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
album && renderItem(
|
||||||
|
appText.about.recentAlbum,
|
||||||
|
<PhotoAlbum
|
||||||
|
album={album}
|
||||||
|
type="text-only"
|
||||||
|
contrast="high"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
tag && renderItem(
|
||||||
|
appText.about.popularTag,
|
||||||
|
<PhotoTag
|
||||||
|
tag={tag}
|
||||||
|
type="text-only"
|
||||||
|
contrast="high"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
].filter(Boolean), [
|
||||||
|
appText.about,
|
||||||
|
photosCount,
|
||||||
|
photosOldest,
|
||||||
|
camera,
|
||||||
|
lens,
|
||||||
|
recipe,
|
||||||
|
film,
|
||||||
|
album,
|
||||||
|
tag,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimateItems
|
||||||
|
type="bottom"
|
||||||
|
items={[<div
|
||||||
|
key="about-page"
|
||||||
|
className="space-y-12 mt-5"
|
||||||
|
>
|
||||||
|
<AppGrid
|
||||||
|
contentMain={<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-4 sm:gap-6">
|
||||||
|
<PhotoAvatar
|
||||||
|
photo={photoAvatar}
|
||||||
|
placeholder={<LuUser size={22} className="text-dim" />}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx('sm:flex items-center justify-between grow')}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">
|
||||||
|
{title || appText.about.titleDefault}
|
||||||
|
</div>
|
||||||
|
{subhead &&
|
||||||
|
<div>{subhead}</div>}
|
||||||
|
</div>
|
||||||
|
{lastUpdated && <div className={clsx('text-dim')}>
|
||||||
|
{appText.about.updated(
|
||||||
|
formatDistanceToNowStrict(lastUpdated),
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
{isUserSignedIn && <AdminAboutMenu />}
|
||||||
|
</div>
|
||||||
|
{description
|
||||||
|
? <div className="text-medium [&>*>a]:underline">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
: isUserSignedIn &&
|
||||||
|
<Link
|
||||||
|
href={PATH_ADMIN_ABOUT_EDIT}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-center gap-2.5',
|
||||||
|
'border border-dashed border-medium rounded-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={<LuCirclePlus size={22} />}
|
||||||
|
includeContainer={false}
|
||||||
|
className="gap-3! p-6!"
|
||||||
|
>
|
||||||
|
Add optional description
|
||||||
|
</AdminEmptyState>
|
||||||
|
</Link>}
|
||||||
|
<AnimateItems
|
||||||
|
className={clsx(
|
||||||
|
'grid gap-x-2 gap-y-6 grid-cols-2 lg:grid-cols-4',
|
||||||
|
)}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
</div>} />
|
||||||
|
{photoHero &&
|
||||||
|
<PhotoLarge photo={photoHero} />}
|
||||||
|
</div>]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/about/AdminAboutEditPage.tsx
Normal file
126
src/about/AdminAboutEditPage.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PATH_ABOUT } from '@/app/path';
|
||||||
|
import LinkWithStatus from '@/components/LinkWithStatus';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { About, AboutInsert } from '.';
|
||||||
|
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||||
|
import AdminChildPage from '@/components/AdminChildPage';
|
||||||
|
import { updateAboutAction } from './actions';
|
||||||
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
|
import { Photo } from '@/photo';
|
||||||
|
import PhotoAvatar from '@/photo/PhotoAvatar';
|
||||||
|
import PhotoMedium from '@/photo/PhotoMedium';
|
||||||
|
import clsx from 'clsx/lite';
|
||||||
|
import useDynamicPhoto from '@/photo/useDynamicPhoto';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
export default function AdminAboutEditPage({
|
||||||
|
about,
|
||||||
|
photoAvatar: _photoAvatar,
|
||||||
|
photoHero: _photoHero,
|
||||||
|
}: {
|
||||||
|
about?: About
|
||||||
|
photoAvatar?: Photo
|
||||||
|
photoHero?: Photo
|
||||||
|
shouldResizeImages?: boolean
|
||||||
|
}) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
|
const [aboutForm, setAboutForm] = useState<Partial<AboutInsert>>(about ?? {});
|
||||||
|
|
||||||
|
const {
|
||||||
|
photo: photoAvatar,
|
||||||
|
isLoading: isLoadingPhotoAvatar,
|
||||||
|
} = useDynamicPhoto({
|
||||||
|
initialPhoto: _photoAvatar,
|
||||||
|
photoId: aboutForm?.photoIdAvatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
photo: photoHero,
|
||||||
|
isLoading: isLoadingPhotoHero,
|
||||||
|
} = useDynamicPhoto({
|
||||||
|
initialPhoto: _photoHero,
|
||||||
|
photoId: aboutForm?.photoIdHero,
|
||||||
|
});
|
||||||
|
|
||||||
|
const convertUrlToPhotoId = (url?: string) => url?.split('/').pop();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminChildPage
|
||||||
|
backPath={PATH_ABOUT}
|
||||||
|
backLabel="About"
|
||||||
|
breadcrumb="Edit About Page"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-12 mt-12"
|
||||||
|
action={updateAboutAction}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PhotoAvatar photo={photoAvatar} />
|
||||||
|
<FieldsetWithStatus
|
||||||
|
id="photoIdAvatar"
|
||||||
|
label="Avatar Photo Id"
|
||||||
|
spellCheck={false}
|
||||||
|
value={aboutForm?.photoIdAvatar ?? ''}
|
||||||
|
onChange={photoIdAvatar => setAboutForm(form =>
|
||||||
|
({ ...form, photoIdAvatar: convertUrlToPhotoId(photoIdAvatar) }))}
|
||||||
|
loading={isLoadingPhotoAvatar}
|
||||||
|
/>
|
||||||
|
<FieldsetWithStatus
|
||||||
|
label="Title"
|
||||||
|
value={aboutForm?.title ?? ''}
|
||||||
|
placeholder={appText.about.titleDefault}
|
||||||
|
onChange={title => setAboutForm(form =>
|
||||||
|
({ ...form, title }))}
|
||||||
|
/>
|
||||||
|
<FieldsetWithStatus
|
||||||
|
label="Subhead"
|
||||||
|
type={!aboutForm?.title ? 'hidden' : undefined}
|
||||||
|
value={aboutForm?.subhead ?? ''}
|
||||||
|
onChange={subhead => setAboutForm(form =>
|
||||||
|
({ ...form, subhead }))}
|
||||||
|
/>
|
||||||
|
<FieldsetWithStatus
|
||||||
|
label="Description"
|
||||||
|
type="textarea"
|
||||||
|
value={aboutForm?.description ?? ''}
|
||||||
|
onChange={description => setAboutForm(form =>
|
||||||
|
({ ...form, description }))}
|
||||||
|
/>
|
||||||
|
<FieldsetWithStatus
|
||||||
|
id="photoIdHero"
|
||||||
|
label="Hero Photo Id"
|
||||||
|
spellCheck={false}
|
||||||
|
value={aboutForm?.photoIdHero ?? ''}
|
||||||
|
onChange={photoIdHero => setAboutForm(form =>
|
||||||
|
({ ...form, photoIdHero: convertUrlToPhotoId(photoIdHero) }))}
|
||||||
|
loading={isLoadingPhotoHero}
|
||||||
|
/>
|
||||||
|
{photoHero &&
|
||||||
|
<div className={clsx(
|
||||||
|
'w-24 overflow-hidden rounded-md',
|
||||||
|
'border border-medium bg-dim',
|
||||||
|
)}>
|
||||||
|
<PhotoMedium photo={photoHero} />
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<LinkWithStatus
|
||||||
|
href={PATH_ABOUT}
|
||||||
|
className="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</LinkWithStatus>
|
||||||
|
<SubmitButtonWithStatus
|
||||||
|
hideText="never"
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</SubmitButtonWithStatus>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminChildPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/about/AdminAboutMenu.tsx
Normal file
18
src/about/AdminAboutMenu.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import MoreMenu from '@/components/more/MoreMenu';
|
||||||
|
import { PATH_ADMIN_ABOUT_EDIT } from '@/app/path';
|
||||||
|
import IconEdit from '@/components/icons/IconEdit';
|
||||||
|
|
||||||
|
export default function AdminAlbumMenu() {
|
||||||
|
return (
|
||||||
|
<MoreMenu
|
||||||
|
ariaLabel="About menu"
|
||||||
|
sections={[{
|
||||||
|
items: [{
|
||||||
|
label: 'Edit Page',
|
||||||
|
icon: <IconEdit size={15} />,
|
||||||
|
href: PATH_ADMIN_ABOUT_EDIT,
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/about/actions.ts
Normal file
18
src/about/actions.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidateAboutKey } from '@/cache';
|
||||||
|
import { upsertAbout } from './query';
|
||||||
|
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { PATH_ABOUT } from '@/app/path';
|
||||||
|
import { convertFormDataToAbout } from './form';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export const updateAboutAction = async (formData: FormData) =>
|
||||||
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
|
const about = convertFormDataToAbout(formData);
|
||||||
|
await upsertAbout(about);
|
||||||
|
revalidateAboutKey();
|
||||||
|
revalidatePath(PATH_ABOUT);
|
||||||
|
redirect(PATH_ABOUT);
|
||||||
|
});
|
||||||
9
src/about/cache.ts
Normal file
9
src/about/cache.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { unstable_cache } from 'next/cache';
|
||||||
|
import { getAbout } from './query';
|
||||||
|
import { KEY_ABOUT, KEY_PHOTOS } from '@/cache';
|
||||||
|
|
||||||
|
export const getAboutCached =
|
||||||
|
unstable_cache(
|
||||||
|
getAbout,
|
||||||
|
[KEY_PHOTOS, KEY_ABOUT],
|
||||||
|
);
|
||||||
13
src/about/form.ts
Normal file
13
src/about/form.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { AboutInsert } from '.';
|
||||||
|
|
||||||
|
export const convertFormDataToAbout = (formData: FormData): AboutInsert => {
|
||||||
|
const id = formData.get('id');
|
||||||
|
return {
|
||||||
|
id: id ? Number(id) : 0,
|
||||||
|
title: (formData.get('title') as string) || undefined,
|
||||||
|
subhead: (formData.get('subhead') as string) || undefined,
|
||||||
|
description: (formData.get('description') as string) || undefined,
|
||||||
|
photoIdAvatar: (formData.get('photoIdAvatar') as string) || undefined,
|
||||||
|
photoIdHero: (formData.get('photoIdHero') as string) || undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
13
src/about/index.ts
Normal file
13
src/about/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export interface AboutInsert {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
subhead?: string
|
||||||
|
description?: string
|
||||||
|
photoIdAvatar?: string
|
||||||
|
photoIdHero?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface About extends AboutInsert {
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
63
src/about/query.ts
Normal file
63
src/about/query.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { sql } from '@/platforms/postgres';
|
||||||
|
import { About, AboutInsert } from '.';
|
||||||
|
import { safelyQuery } from '@/db/query';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
|
const ABOUT_ID = 1;
|
||||||
|
|
||||||
|
export const createAboutTable = () =>
|
||||||
|
sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS about (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255),
|
||||||
|
subhead TEXT,
|
||||||
|
description TEXT,
|
||||||
|
photo_id_avatar VARCHAR(8) REFERENCES photos(id),
|
||||||
|
photo_id_hero VARCHAR(8) REFERENCES photos(id),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const upsertAbout = (about: AboutInsert) =>
|
||||||
|
safelyQuery(() => sql`
|
||||||
|
INSERT INTO about (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
subhead,
|
||||||
|
description,
|
||||||
|
photo_id_avatar,
|
||||||
|
photo_id_hero,
|
||||||
|
updated_at,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
${ABOUT_ID},
|
||||||
|
${about.title},
|
||||||
|
${about.subhead},
|
||||||
|
${about.description},
|
||||||
|
${about.photoIdAvatar},
|
||||||
|
${about.photoIdHero},
|
||||||
|
${new Date().toISOString()},
|
||||||
|
${new Date().toISOString()}
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
subhead = EXCLUDED.subhead,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
photo_id_avatar = EXCLUDED.photo_id_avatar,
|
||||||
|
photo_id_hero = EXCLUDED.photo_id_hero,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id
|
||||||
|
`.then(({ rows }) => rows[0]?.id as number)
|
||||||
|
, 'insertAbout');
|
||||||
|
|
||||||
|
export const getAbout = () =>
|
||||||
|
safelyQuery(() => sql`
|
||||||
|
SELECT * FROM about LIMIT 1
|
||||||
|
`.then(({ rows }) => rows[0]
|
||||||
|
? camelcaseKeys(
|
||||||
|
rows[0] as unknown as Record<string, unknown>,
|
||||||
|
) as unknown as About
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
, 'getAbout');
|
||||||
@ -34,6 +34,11 @@ import { MoreMenuSection } from '@/components/more/MoreMenu';
|
|||||||
import { FiXSquare } from 'react-icons/fi';
|
import { FiXSquare } from 'react-icons/fi';
|
||||||
import { useSelectPhotosState } from './select/SelectPhotosState';
|
import { useSelectPhotosState } from './select/SelectPhotosState';
|
||||||
import IconAlbum from '@/components/icons/IconAlbum';
|
import IconAlbum from '@/components/icons/IconAlbum';
|
||||||
|
import { SHOW_ABOUT_PAGE } from '@/app/config';
|
||||||
|
import {
|
||||||
|
HEIGHT_CLASS,
|
||||||
|
SWITCHER_ITEM_WIDTH,
|
||||||
|
} from '@/components/switcher/SwitcherItem';
|
||||||
|
|
||||||
export default function AdminAppMenu({
|
export default function AdminAppMenu({
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -220,23 +225,25 @@ export default function AdminAppMenu({
|
|||||||
return (
|
return (
|
||||||
<SwitcherItemMenu
|
<SwitcherItemMenu
|
||||||
{...{ isOpen, setIsOpen }}
|
{...{ isOpen, setIsOpen }}
|
||||||
icon={<div className="w-[28px] h-[28px] overflow-hidden">
|
icon={<div className={`w-full ${HEIGHT_CLASS} overflow-hidden`}>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'relative flex flex-col items-center justify-center gap-2',
|
'relative flex flex-col items-center gap-2',
|
||||||
'translate-y-[-18px]',
|
'translate-y-[-16px]',
|
||||||
)}>
|
)}>
|
||||||
<IoArrowDown size={16} className="shrink-0" />
|
<IoArrowDown size={16} className="shrink-0" />
|
||||||
<IoArrowUp size={16} className="shrink-0" />
|
<IoArrowUp size={16} className="shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={12}
|
sideOffset={10}
|
||||||
alignOffset={-84}
|
alignOffset={SHOW_ABOUT_PAGE
|
||||||
|
? -(SWITCHER_ITEM_WIDTH * 3)
|
||||||
|
: -(SWITCHER_ITEM_WIDTH * 2)}
|
||||||
onOpen={refreshAdminData}
|
onOpen={refreshAdminData}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
ariaLabel="Admin Menu"
|
ariaLabel="Admin Menu"
|
||||||
classNameButtonOpen={clsx(
|
classNameButtonOpen={clsx(
|
||||||
'[&>*>*]:translate-y-[6px]',
|
'[&>*>*]:translate-y-[8px]',
|
||||||
'[&>*>*]:duration-300',
|
'[&>*>*]:duration-300',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,16 +5,19 @@ import { IoInformationCircleOutline } from 'react-icons/io5';
|
|||||||
export default function AdminEmptyState({
|
export default function AdminEmptyState({
|
||||||
icon,
|
icon,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
includeContainer = true,
|
includeContainer = true,
|
||||||
}: {
|
}: {
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
includeContainer?: boolean
|
includeContainer?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex flex-col gap-4 justify-center items-center p-8',
|
'flex flex-col gap-4 justify-center items-center p-8',
|
||||||
includeContainer &&'component-surface shadow-xs',
|
includeContainer && 'component-surface shadow-xs',
|
||||||
|
className,
|
||||||
)}>
|
)}>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'size-14 flex justify-center items-center',
|
'size-14 flex justify-center items-center',
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export default function AdminUploadsTableRow({
|
|||||||
'flex items-center grow',
|
'flex items-center grow',
|
||||||
'transition-opacity',
|
'transition-opacity',
|
||||||
'rounded-lg overflow-hidden',
|
'rounded-lg overflow-hidden',
|
||||||
'border-medium bg-extra-dim',
|
'border border-medium bg-extra-dim',
|
||||||
isAdding && !isComplete && status !== 'adding' && 'opacity-30',
|
isAdding && !isComplete && status !== 'adding' && 'opacity-30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import {
|
|||||||
} from '@/photo/ai';
|
} from '@/photo/ai';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/path';
|
import { PATH_ABOUT, PATH_FEED_JSON, PATH_RSS_XML } from '@/app/path';
|
||||||
import { APP_DEFAULT_SORT_BY, DEFAULT_SORT_BY_OPTIONS } from '@/photo/sort';
|
import { APP_DEFAULT_SORT_BY, DEFAULT_SORT_BY_OPTIONS } from '@/photo/sort';
|
||||||
import {
|
import {
|
||||||
AdminConfigSection,
|
AdminConfigSection,
|
||||||
@ -68,8 +68,8 @@ export default function AdminAppConfigurationClient({
|
|||||||
hasNavTitle,
|
hasNavTitle,
|
||||||
navCaption,
|
navCaption,
|
||||||
hasNavCaption,
|
hasNavCaption,
|
||||||
pageAbout,
|
sidebarText,
|
||||||
hasPageAbout,
|
hasSidebarText,
|
||||||
// Performance
|
// Performance
|
||||||
isStaticallyOptimized,
|
isStaticallyOptimized,
|
||||||
arePhotosStaticallyOptimized,
|
arePhotosStaticallyOptimized,
|
||||||
@ -106,6 +106,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
colorSortChromaCutoff,
|
colorSortChromaCutoff,
|
||||||
isSortWithPriority,
|
isSortWithPriority,
|
||||||
// Display
|
// Display
|
||||||
|
showAboutPage,
|
||||||
showKeyboardShortcutTooltips,
|
showKeyboardShortcutTooltips,
|
||||||
showExifInfo,
|
showExifInfo,
|
||||||
showZoomControls,
|
showZoomControls,
|
||||||
@ -377,7 +378,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={hasAuthSecret}
|
status={hasAuthSecret}
|
||||||
isPending={!hasAuthSecret && isAnalyzingConfiguration}
|
isPending={!hasAuthSecret && isAnalyzingConfiguration}
|
||||||
>
|
>
|
||||||
Store auth secret in environment variable:
|
Store auth secret in environment variable
|
||||||
{!hasAuthSecret &&
|
{!hasAuthSecret &&
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<SecretGenerator {...{ secret }} />
|
<SecretGenerator {...{ secret }} />
|
||||||
@ -390,7 +391,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
Store admin email/password
|
Store admin email/password
|
||||||
{' '}
|
{' '}
|
||||||
in environment variables:
|
in environment variables
|
||||||
{renderEnvVars([
|
{renderEnvVars([
|
||||||
'ADMIN_EMAIL',
|
'ADMIN_EMAIL',
|
||||||
'ADMIN_PASSWORD',
|
'ADMIN_PASSWORD',
|
||||||
@ -405,8 +406,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
{renderContent(locale)}
|
{renderContent(locale)}
|
||||||
Store in environment variable
|
Check README for
|
||||||
(check README for
|
|
||||||
{' '}
|
{' '}
|
||||||
<AdminLink
|
<AdminLink
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
@ -414,7 +414,6 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
supported languages
|
supported languages
|
||||||
</AdminLink>
|
</AdminLink>
|
||||||
):
|
|
||||||
{renderEnvVars(['NEXT_PUBLIC_LOCALE'])}
|
{renderEnvVars(['NEXT_PUBLIC_LOCALE'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -422,8 +421,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={hasDomain}
|
status={hasDomain}
|
||||||
>
|
>
|
||||||
{renderContent(domain)}
|
{renderContent(domain)}
|
||||||
Store in environment variable
|
Used in explicit share urls (seen in nav if no title is defined)
|
||||||
(used in explicit share urls, seen in nav if no title is defined):
|
|
||||||
{renderEnvVars(['NEXT_PUBLIC_DOMAIN'])}
|
{renderEnvVars(['NEXT_PUBLIC_DOMAIN'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -432,8 +430,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
showWarning
|
showWarning
|
||||||
>
|
>
|
||||||
{renderContent(metaTitle)}
|
{renderContent(metaTitle)}
|
||||||
Store in environment variable
|
Seen in search results and browser tab
|
||||||
(seen in search results and browser tab):
|
|
||||||
{renderEnvVars(['NEXT_PUBLIC_META_TITLE'])}
|
{renderEnvVars(['NEXT_PUBLIC_META_TITLE'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
{!simplifiedView && <>
|
{!simplifiedView && <>
|
||||||
@ -443,8 +440,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
{renderContent(metaDescription)}
|
{renderContent(metaDescription)}
|
||||||
Store in environment variable
|
Seen in search results
|
||||||
(seen in search results):
|
|
||||||
{renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])}
|
{renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -453,7 +449,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
{renderContent(navTitle)}
|
{renderContent(navTitle)}
|
||||||
Store in environment variable (replaces domain in top-right nav):
|
Replaces domain in top-right nav
|
||||||
{renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])}
|
{renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -462,18 +458,17 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
{hasNavCaption && renderContent(navCaption)}
|
{hasNavCaption && renderContent(navCaption)}
|
||||||
Store in environment variable
|
Seen in top-right nav, under title
|
||||||
(seen in top-right nav, under title):
|
|
||||||
{renderEnvVars(['NEXT_PUBLIC_NAV_CAPTION'])}
|
{renderEnvVars(['NEXT_PUBLIC_NAV_CAPTION'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Page about"
|
title="Sidebar text"
|
||||||
status={hasPageAbout}
|
status={hasSidebarText}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
{hasPageAbout && renderContent(pageAbout)}
|
{hasSidebarText && renderContent(sidebarText)}
|
||||||
Store in environment variable (seen in sidebar):
|
Seen in sidebar on desktop grid view
|
||||||
{renderEnvVars(['NEXT_PUBLIC_PAGE_ABOUT'])}
|
{renderEnvVars(['NEXT_PUBLIC_SIDEBAR_TEXT'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>}
|
</>}
|
||||||
</>;
|
</>;
|
||||||
@ -510,7 +505,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
text descriptions, including an invisible field called
|
text descriptions, including an invisible field called
|
||||||
{' '}
|
{' '}
|
||||||
{'"Semantic Description"'}, which supports CMD-K search
|
{'"Semantic Description"'}, which supports CMD-K search
|
||||||
and image accessibility:
|
and image accessibility
|
||||||
{renderEnvVars(['OPENAI_SECRET_KEY'])}
|
{renderEnvVars(['OPENAI_SECRET_KEY'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -525,7 +520,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
connection: { provider: 'Google Places', error: locationError},
|
connection: { provider: 'Google Places', error: locationError},
|
||||||
})}
|
})}
|
||||||
Store Google Places API key in order to add location meta
|
Store Google Places API key in order to add location meta
|
||||||
to entities like albums:
|
to entities like albums
|
||||||
{renderEnvVars(['GOOGLE_PLACES_API_KEY'])}
|
{renderEnvVars(['GOOGLE_PLACES_API_KEY'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -554,7 +549,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
{' '}
|
{' '}
|
||||||
(default: {renderCommaSeparatedList(
|
(default: {renderCommaSeparatedList(
|
||||||
AI_AUTO_GENERATED_FIELDS_DEFAULT,
|
AI_AUTO_GENERATED_FIELDS_DEFAULT,
|
||||||
)}):
|
)})
|
||||||
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
|
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -565,7 +560,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
Store model in environment variable to use
|
Store model in environment variable to use
|
||||||
alternate OpenAI model
|
alternate OpenAI model
|
||||||
{' '}
|
{' '}
|
||||||
{'(set to \'compatible\' to use gpt-4o):'}
|
{'(set to \'compatible\' to use gpt-4o)'}
|
||||||
{renderEnvVars(['OPENAI_MODEL'])}
|
{renderEnvVars(['OPENAI_MODEL'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -574,7 +569,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Store base URL in environment variable to use
|
Store base URL in environment variable to use
|
||||||
alternate OpenAI-compatible providers:
|
alternate OpenAI-compatible providers
|
||||||
{renderEnvVars(['OPENAI_BASE_URL'])}
|
{renderEnvVars(['OPENAI_BASE_URL'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -587,7 +582,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to make site more responsive
|
Set environment variable to {'"1"'} to make site more responsive
|
||||||
by enabling static optimization
|
by enabling static optimization
|
||||||
(i.e., rendering pages and images at build time):
|
(i.e., rendering pages and images at build time)
|
||||||
<div>
|
<div>
|
||||||
{renderSubStatusWithEnvVar(
|
{renderSubStatusWithEnvVar(
|
||||||
arePhotosStaticallyOptimized ? 'checked' : 'optional',
|
arePhotosStaticallyOptimized ? 'checked' : 'optional',
|
||||||
@ -614,7 +609,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to prevent
|
Set environment variable to {'"1"'} to prevent
|
||||||
image uploads being compressed before storing:
|
image uploads being compressed before storing
|
||||||
{renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])}
|
{renderEnvVars(['NEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -625,7 +620,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
Set environment variable from {'"1-100"'}
|
Set environment variable from {'"1-100"'}
|
||||||
{' '}
|
{' '}
|
||||||
to control the quality of large photos
|
to control the quality of large photos
|
||||||
({'"100"'} represents highest quality/largest size):
|
({'"100"'} represents highest quality/largest size)
|
||||||
{renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])}
|
{renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -634,7 +629,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to prevent
|
Set environment variable to {'"1"'} to prevent
|
||||||
image blur data being stored and displayed:
|
image blur data being stored and displayed
|
||||||
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
|
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -650,7 +645,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
Configure order and visibility of categories
|
Configure order and visibility of categories
|
||||||
(seen in grid sidebar and CMD-K results)
|
(seen in grid sidebar and CMD-K results)
|
||||||
by storing comma-separated values
|
by storing comma-separated values
|
||||||
(default: {renderCommaSeparatedList(DEFAULT_CATEGORY_KEYS)}):
|
(default: {renderCommaSeparatedList(DEFAULT_CATEGORY_KEYS)})
|
||||||
</div>
|
</div>
|
||||||
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
|
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
@ -662,7 +657,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
Set environment variable to {'"1"'} to prevent categories
|
Set environment variable to {'"1"'} to prevent categories
|
||||||
displaying on mobile grid view:
|
displaying on mobile grid view
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORIES_ON_MOBILE'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORIES_ON_MOBILE'])}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -675,7 +670,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
Set environment variable to {'"1"'} to prevent images
|
Set environment variable to {'"1"'} to prevent images
|
||||||
displaying when hovering over category links:
|
displaying when hovering over category links
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -727,7 +722,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"none"'}, {'"toggle"'} (default),
|
Set environment variable to {'"none"'}, {'"toggle"'} (default),
|
||||||
or {'"menu"'}, to control sort UI on grid/full homepages:
|
or {'"menu"'}, to control sort UI on grid/full homepages
|
||||||
{renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])}
|
{renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -739,7 +734,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
Set environment variable to {'"1"'} to enable color-based sorting
|
Set environment variable to {'"1"'} to enable color-based sorting
|
||||||
(forces nav sort control to {'"menu,"'} flags photos missing
|
(forces nav sort control to {'"menu,"'} flags photos missing
|
||||||
color data in admin dashboard)—color identification
|
color data in admin dashboard)—color identification
|
||||||
benefits greatly from AI being enabled:
|
benefits greatly from AI being enabled
|
||||||
{renderEnvVars([
|
{renderEnvVars([
|
||||||
'NEXT_PUBLIC_COLOR_SORT',
|
'NEXT_PUBLIC_COLOR_SORT',
|
||||||
])}
|
])}
|
||||||
@ -753,7 +748,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
Configure which colors start first
|
Configure which colors start first
|
||||||
(accepts a hue of 0 to 360, default: 80)
|
(accepts a hue of 0 to 360, default: 80)
|
||||||
and which are considered sufficiently vibrant
|
and which are considered sufficiently vibrant
|
||||||
(accepts a chroma of 0 to 0.37, default: 0.05):
|
(accepts a chroma of 0 to 0.37, default: 0.05)
|
||||||
<div>
|
<div>
|
||||||
<EnvVar
|
<EnvVar
|
||||||
variable="NEXT_PUBLIC_COLOR_SORT_STARTING_HUE"
|
variable="NEXT_PUBLIC_COLOR_SORT_STARTING_HUE"
|
||||||
@ -777,19 +772,29 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to take priority field
|
Set environment variable to {'"1"'} to take priority field
|
||||||
into account when sorting photos (enabling may have
|
into account when sorting photos (enabling may have
|
||||||
performance consequences):
|
performance consequences)
|
||||||
{renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])}
|
{renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
case 'Display':
|
case 'Display':
|
||||||
return <>
|
return <>
|
||||||
|
<ChecklistRow
|
||||||
|
title="Show about page"
|
||||||
|
status={showAboutPage}
|
||||||
|
optional
|
||||||
|
>
|
||||||
|
Set environment variable to {'"1"'} to hide
|
||||||
|
{' '}
|
||||||
|
{renderLink(PATH_ABOUT)} page
|
||||||
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_ABOUT_PAGE'])}
|
||||||
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Show keyboard shortcut tooltips"
|
title="Show keyboard shortcut tooltips"
|
||||||
status={showKeyboardShortcutTooltips}
|
status={showKeyboardShortcutTooltips}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to hide keyboard shortcut
|
Set environment variable to {'"1"'} to hide keyboard shortcut
|
||||||
tooltips in areas like the main nav, and previous/next photo links:
|
tooltips in areas like the main nav, and previous/next photo links
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -797,7 +802,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={showExifInfo}
|
status={showExifInfo}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to hide EXIF data:
|
Set environment variable to {'"1"'} to hide EXIF data
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -806,7 +811,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to hide
|
Set environment variable to {'"1"'} to hide
|
||||||
fullscreen photo zoom controls:
|
fullscreen photo zoom controls
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -815,7 +820,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to hide
|
Set environment variable to {'"1"'} to hide
|
||||||
taken at time from photo meta:
|
taken at time from photo meta
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_TAKEN_AT_TIME'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -823,7 +828,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={showRepoLink}
|
status={showRepoLink}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to hide footer link:
|
Set environment variable to {'"1"'} to hide footer link
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -835,7 +840,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to show grid layout
|
Set environment variable to {'"1"'} to show grid layout
|
||||||
on homepage:
|
on homepage
|
||||||
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
|
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -845,7 +850,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
Set environment variable to any number to enforce aspect ratio
|
Set environment variable to any number to enforce aspect ratio
|
||||||
{' '}
|
{' '}
|
||||||
(default is {'"1"'}, i.e., square)—set to {'"0"'} to disable:
|
(default is {'"1"'}, i.e., square)—set to {'"0"'} to disable
|
||||||
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
|
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -855,7 +860,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to ensure large thumbnails
|
Set environment variable to {'"1"'} to ensure large thumbnails
|
||||||
on photo grid views (if not configured, density is based on
|
on photo grid views (if not configured, density is based on
|
||||||
aspect ratio):
|
aspect ratio)
|
||||||
{renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])}
|
{renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -870,7 +875,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
{' '}
|
{' '}
|
||||||
to configure initial theme
|
to configure initial theme
|
||||||
{' '}
|
{' '}
|
||||||
(defaults to {'\'system\''}):
|
(defaults to {'\'system\''})
|
||||||
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
|
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -880,7 +885,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to constrain the size
|
Set environment variable to {'"1"'} to constrain the size
|
||||||
{' '}
|
{' '}
|
||||||
of each photo, and display a surrounding border:
|
of each photo, and display a surrounding border
|
||||||
<div className="pt-1 flex flex-col gap-1">
|
<div className="pt-1 flex flex-col gap-1">
|
||||||
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" />
|
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" />
|
||||||
</div>
|
</div>
|
||||||
@ -891,7 +896,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable hex values (e.g., #cccccc)
|
Set environment variable hex values (e.g., #cccccc)
|
||||||
to override matte colors:
|
to override matte colors
|
||||||
<div className="pt-1 flex flex-col gap-1">
|
<div className="pt-1 flex flex-col gap-1">
|
||||||
<EnvVar
|
<EnvVar
|
||||||
variable="NEXT_PUBLIC_MATTE_COLOR"
|
variable="NEXT_PUBLIC_MATTE_COLOR"
|
||||||
@ -912,7 +917,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to disable
|
Set environment variable to {'"1"'} to disable
|
||||||
collection/display of location-based data:
|
collection/display of location-based data
|
||||||
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -921,7 +926,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to enable
|
Set environment variable to {'"1"'} to enable
|
||||||
public photo downloads for all visitors:
|
public photo downloads for all visitors
|
||||||
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
|
{renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -943,9 +948,11 @@ export default function AdminAppConfigurationClient({
|
|||||||
status={areSiteFeedsEnabled}
|
status={areSiteFeedsEnabled}
|
||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to enable feeds at
|
Set environment variable to {'"1"'} to enable
|
||||||
{' '}
|
{' '}
|
||||||
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}:
|
{renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}
|
||||||
|
{' '}
|
||||||
|
feeds
|
||||||
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
|
{renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -954,7 +961,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"BOTTOM"'} to
|
Set environment variable to {'"BOTTOM"'} to
|
||||||
keep OG image text bottom aligned (default is {'"top"'}):
|
keep OG image text bottom aligned (default is {'"top"'})
|
||||||
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
|
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -983,7 +990,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
</MaskedScroll>)}
|
</MaskedScroll>)}
|
||||||
</div>}
|
</div>}
|
||||||
Set environment variable to comma-separated list of URLs
|
Set environment variable to comma-separated list of URLs
|
||||||
to be added to the bottom of the body tag via {'"next/script"'}:
|
to be added to the bottom of the body tag via {'"next/script"'}
|
||||||
{renderEnvVars(['PAGE_SCRIPT_URLS'])}
|
{renderEnvVars(['PAGE_SCRIPT_URLS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -995,7 +1002,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to disable build identifier
|
Set environment variable to {'"1"'} to disable build identifier
|
||||||
and admin configuration export:
|
and admin configuration export
|
||||||
{renderEnvVars(['DISABLE_DEBUG_OUTPUTS'])}
|
{renderEnvVars(['DISABLE_DEBUG_OUTPUTS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
@ -1007,7 +1014,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to temporarily enable
|
Set environment variable to {'"1"'} to temporarily enable
|
||||||
features like photo matting, baseline grid, etc.:
|
features like photo matting, baseline grid, etc.
|
||||||
{renderEnvVars(['ADMIN_DEBUG_TOOLS'])}
|
{renderEnvVars(['ADMIN_DEBUG_TOOLS'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -1016,7 +1023,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to enable
|
Set environment variable to {'"1"'} to enable
|
||||||
console output for all sql queries:
|
console output for all sql queries
|
||||||
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
|
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
@ -1025,7 +1032,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
optional
|
optional
|
||||||
>
|
>
|
||||||
Set environment variable to {'"1"'} to enable
|
Set environment variable to {'"1"'} to enable
|
||||||
storage debugging:
|
storage debugging
|
||||||
{renderEnvVars(['ADMIN_STORAGE_DEBUG'])}
|
{renderEnvVars(['ADMIN_STORAGE_DEBUG'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</>;
|
</>;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export default async function AlbumHeader({
|
|||||||
showAlbumMeta?: boolean
|
showAlbumMeta?: boolean
|
||||||
}) {
|
}) {
|
||||||
const appText = await getAppText();
|
const appText = await getAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
album={album}
|
album={album}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import SwitcherItem from '@/components/switcher/SwitcherItem';
|
|||||||
import IconFull from '@/components/icons/IconFull';
|
import IconFull from '@/components/icons/IconFull';
|
||||||
import IconGrid from '@/components/icons/IconGrid';
|
import IconGrid from '@/components/icons/IconGrid';
|
||||||
import {
|
import {
|
||||||
|
PATH_ABOUT,
|
||||||
PATH_FULL_INFERRED,
|
PATH_FULL_INFERRED,
|
||||||
PATH_GRID_INFERRED,
|
PATH_GRID_INFERRED,
|
||||||
} from '@/app/path';
|
} from '@/app/path';
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
GRID_HOMEPAGE_ENABLED,
|
GRID_HOMEPAGE_ENABLED,
|
||||||
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
NAV_SORT_CONTROL,
|
NAV_SORT_CONTROL,
|
||||||
|
SHOW_ABOUT_PAGE,
|
||||||
} from './config';
|
} from './config';
|
||||||
import AdminAppMenu from '@/admin/AdminAppMenu';
|
import AdminAppMenu from '@/admin/AdminAppMenu';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
@ -26,8 +28,9 @@ import { getSortStateFromPath } from '@/photo/sort/path';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import SortMenu from '@/photo/sort/SortMenu';
|
import SortMenu from '@/photo/sort/SortMenu';
|
||||||
import { SWR_KEYS } from '@/swr';
|
import { SWR_KEYS } from '@/swr';
|
||||||
|
import IconAbout from '@/components/icons/IconAbout';
|
||||||
|
|
||||||
export type SwitcherSelection = 'full' | 'grid' | 'admin';
|
export type SwitcherSelection = 'full' | 'grid' | 'about' | 'admin';
|
||||||
|
|
||||||
const GAP_CLASS_RIGHT = 'mr-1.5 sm:mr-2';
|
const GAP_CLASS_RIGHT = 'mr-1.5 sm:mr-2';
|
||||||
const GAP_CLASS_LEFT = 'ml-0.5 sm:ml-1';
|
const GAP_CLASS_LEFT = 'ml-0.5 sm:ml-1';
|
||||||
@ -36,10 +39,12 @@ export default function AppViewSwitcher({
|
|||||||
currentSelection,
|
currentSelection,
|
||||||
className,
|
className,
|
||||||
animate = true,
|
animate = true,
|
||||||
|
hideSortControl,
|
||||||
}: {
|
}: {
|
||||||
currentSelection?: SwitcherSelection
|
currentSelection?: SwitcherSelection
|
||||||
className?: string
|
className?: string
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
|
hideSortControl?: boolean
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
@ -69,7 +74,8 @@ export default function AppViewSwitcher({
|
|||||||
|
|
||||||
const showSortControl =
|
const showSortControl =
|
||||||
NAV_SORT_CONTROL !== 'none' &&
|
NAV_SORT_CONTROL !== 'none' &&
|
||||||
doesPathOfferSort;
|
doesPathOfferSort &&
|
||||||
|
!hideSortControl;
|
||||||
|
|
||||||
const hasLoadedRef = useRef(false);
|
const hasLoadedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -82,6 +88,7 @@ export default function AppViewSwitcher({
|
|||||||
|
|
||||||
const refHrefFull = useRef<HTMLAnchorElement>(null);
|
const refHrefFull = useRef<HTMLAnchorElement>(null);
|
||||||
const refHrefGrid = useRef<HTMLAnchorElement>(null);
|
const refHrefGrid = useRef<HTMLAnchorElement>(null);
|
||||||
|
const refHrefAbout = useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
|
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
|
||||||
|
|
||||||
@ -94,19 +101,19 @@ export default function AppViewSwitcher({
|
|||||||
case KEY_COMMANDS.grid:
|
case KEY_COMMANDS.grid:
|
||||||
if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); }
|
if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); }
|
||||||
break;
|
break;
|
||||||
case KEY_COMMANDS.admin:
|
case KEY_COMMANDS.about:
|
||||||
if (isUserSignedIn) { setIsAdminMenuOpen(true); }
|
if (pathname !== PATH_ABOUT) { refHrefAbout.current?.click(); }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pathname, isUserSignedIn]);
|
}, [pathname]);
|
||||||
useKeydownHandler({ onKeyDown });
|
useKeydownHandler({ onKeyDown });
|
||||||
|
|
||||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||||
|
|
||||||
const renderItemFull =
|
const renderItemFull =
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<IconFull includeTitle={false} />}
|
icon={<IconFull />}
|
||||||
href={pathFull}
|
href={pathFull}
|
||||||
hrefRef={refHrefFull}
|
hrefRef={refHrefFull}
|
||||||
active={currentSelection === 'full'}
|
active={currentSelection === 'full'}
|
||||||
@ -119,7 +126,7 @@ export default function AppViewSwitcher({
|
|||||||
|
|
||||||
const renderItemGrid =
|
const renderItemGrid =
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<IconGrid includeTitle={false} />}
|
icon={<IconGrid />}
|
||||||
href={pathGrid}
|
href={pathGrid}
|
||||||
hrefRef={refHrefGrid}
|
hrefRef={refHrefGrid}
|
||||||
active={currentSelection === 'grid'}
|
active={currentSelection === 'grid'}
|
||||||
@ -141,6 +148,18 @@ export default function AppViewSwitcher({
|
|||||||
>
|
>
|
||||||
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
|
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
|
||||||
{GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid}
|
{GRID_HOMEPAGE_ENABLED ? renderItemFull : renderItemGrid}
|
||||||
|
{SHOW_ABOUT_PAGE &&
|
||||||
|
<SwitcherItem
|
||||||
|
icon={<IconAbout />}
|
||||||
|
href={PATH_ABOUT}
|
||||||
|
hrefRef={refHrefAbout}
|
||||||
|
active={currentSelection === 'about'}
|
||||||
|
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||||
|
content: appText.nav.about,
|
||||||
|
keyCommand: KEY_COMMANDS.about,
|
||||||
|
}}}
|
||||||
|
noPadding
|
||||||
|
/>}
|
||||||
{/* Show spinner if admin is suspected to be logged in */}
|
{/* Show spinner if admin is suspected to be logged in */}
|
||||||
{(isUserSignedInEager && !isUserSignedIn) &&
|
{(isUserSignedInEager && !isUserSignedIn) &&
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
@ -150,7 +169,6 @@ export default function AppViewSwitcher({
|
|||||||
tooltip={{
|
tooltip={{
|
||||||
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||||
content: appText.nav.admin,
|
content: appText.nav.admin,
|
||||||
keyCommand: KEY_COMMANDS.admin,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
@ -166,7 +184,6 @@ export default function AppViewSwitcher({
|
|||||||
tooltip={{
|
tooltip={{
|
||||||
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||||
content: appText.nav.admin,
|
content: appText.nav.admin,
|
||||||
keyCommand: KEY_COMMANDS.admin,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
noPadding
|
noPadding
|
||||||
@ -224,7 +241,7 @@ export default function AppViewSwitcher({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
<Switcher type="borderless">
|
<Switcher type="borderless">
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<IconSearch includeTitle={false} />}
|
icon={<IconSearch />}
|
||||||
onClick={() => setIsCommandKOpen?.(true)}
|
onClick={() => setIsCommandKOpen?.(true)}
|
||||||
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||||
content: appText.nav.search,
|
content: appText.nav.search,
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export default function Footer() {
|
|||||||
? <>
|
? <>
|
||||||
<Link
|
<Link
|
||||||
href={PATH_ADMIN_PHOTOS}
|
href={PATH_ADMIN_PHOTOS}
|
||||||
className="truncate max-w-full"
|
className="truncate max-w-full max-sm:hidden"
|
||||||
>
|
>
|
||||||
{userEmail || userEmailEager}
|
{userEmail || userEmailEager}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -7,6 +7,6 @@ export default async function Nav() {
|
|||||||
return <NavClient
|
return <NavClient
|
||||||
navTitle={NAV_TITLE}
|
navTitle={NAV_TITLE}
|
||||||
navCaption={NAV_CAPTION}
|
navCaption={NAV_CAPTION}
|
||||||
animate={photos.length > 0}
|
isInEmptyState={photos.length === 0}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import AppGrid from '../components/AppGrid';
|
|||||||
import AppViewSwitcher, { SwitcherSelection } from '@/app/AppViewSwitcher';
|
import AppViewSwitcher, { SwitcherSelection } from '@/app/AppViewSwitcher';
|
||||||
import {
|
import {
|
||||||
PATH_ROOT,
|
PATH_ROOT,
|
||||||
|
isPathAbout,
|
||||||
isPathAdmin,
|
isPathAdmin,
|
||||||
isPathFull,
|
isPathFull,
|
||||||
isPathGrid,
|
isPathGrid,
|
||||||
@ -29,11 +30,11 @@ const NAV_HEIGHT_CLASS = NAV_CAPTION
|
|||||||
export default function NavClient({
|
export default function NavClient({
|
||||||
navTitle,
|
navTitle,
|
||||||
navCaption,
|
navCaption,
|
||||||
animate,
|
isInEmptyState,
|
||||||
}: {
|
}: {
|
||||||
navTitle: string
|
navTitle: string
|
||||||
navCaption?: string
|
navCaption?: string
|
||||||
animate: boolean
|
isInEmptyState: boolean
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
@ -65,6 +66,8 @@ export default function NavClient({
|
|||||||
return 'grid';
|
return 'grid';
|
||||||
} else if (isPathFull(pathname)) {
|
} else if (isPathFull(pathname)) {
|
||||||
return 'full';
|
return 'full';
|
||||||
|
} else if (isPathAbout(pathname)) {
|
||||||
|
return 'about';
|
||||||
} else if (isPathProtected(pathname)) {
|
} else if (isPathProtected(pathname)) {
|
||||||
return 'admin';
|
return 'admin';
|
||||||
}
|
}
|
||||||
@ -77,14 +80,14 @@ export default function NavClient({
|
|||||||
contentMain={
|
contentMain={
|
||||||
<AnimateItems
|
<AnimateItems
|
||||||
animateOnFirstLoadOnly
|
animateOnFirstLoadOnly
|
||||||
type={animate && !isPathAdmin(pathname) ? 'bottom' : 'none'}
|
type={!isInEmptyState && !isPathAdmin(pathname) ? 'bottom' : 'none'}
|
||||||
distanceOffset={10}
|
distanceOffset={10}
|
||||||
items={showNav
|
items={showNav
|
||||||
? [<nav
|
? [<nav
|
||||||
key="nav"
|
key="nav"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full flex items-center bg-main',
|
'w-full flex items-center gap-2 bg-main',
|
||||||
NAV_HEIGHT_CLASS,
|
NAV_HEIGHT_CLASS,
|
||||||
// Enlarge nav to ensure it fully masks underlying content
|
// Enlarge nav to ensure it fully masks underlying content
|
||||||
'md:w-[calc(100%+8px)] md:translate-x-[-4px] md:px-[4px]',
|
'md:w-[calc(100%+8px)] md:translate-x-[-4px] md:px-[4px]',
|
||||||
@ -94,6 +97,7 @@ export default function NavClient({
|
|||||||
currentSelection={switcherSelectionForPath()}
|
currentSelection={switcherSelectionForPath()}
|
||||||
className="translate-x-[-1px]"
|
className="translate-x-[-1px]"
|
||||||
animate={hasLoadedWithAnimations && isNavVisible}
|
animate={hasLoadedWithAnimations && isNavVisible}
|
||||||
|
hideSortControl={isInEmptyState}
|
||||||
/>
|
/>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'grow text-right min-w-0',
|
'grow text-right min-w-0',
|
||||||
|
|||||||
@ -66,13 +66,13 @@ export default function TemplateImageResponse({
|
|||||||
color: '#333',
|
color: '#333',
|
||||||
borderRight: '2px solid #333',
|
borderRight: '2px solid #333',
|
||||||
}}>
|
}}>
|
||||||
<IconFull includeTitle={false} width={80} />
|
<IconFull width={80} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
}}>
|
}}>
|
||||||
<IconGrid includeTitle={false} width={80} />
|
<IconGrid width={80} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -146,9 +146,10 @@ export const NAV_TITLE =
|
|||||||
SITE_DOMAIN_SHORT ||
|
SITE_DOMAIN_SHORT ||
|
||||||
META_TITLE;
|
META_TITLE;
|
||||||
|
|
||||||
export const PAGE_ABOUT =
|
export const SIDEBAR_TEXT =
|
||||||
|
process.env.NEXT_PUBLIC_SIDEBAR_TEXT ||
|
||||||
|
// Legacy environment variables
|
||||||
process.env.NEXT_PUBLIC_PAGE_ABOUT ||
|
process.env.NEXT_PUBLIC_PAGE_ABOUT ||
|
||||||
// Legacy environment variable
|
|
||||||
process.env.NEXT_PUBLIC_SITE_ABOUT;
|
process.env.NEXT_PUBLIC_SITE_ABOUT;
|
||||||
|
|
||||||
// STORAGE
|
// STORAGE
|
||||||
@ -323,6 +324,8 @@ export const NAV_SORT_CONTROL = COLOR_SORT_ENABLED
|
|||||||
|
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
|
|
||||||
|
export const SHOW_ABOUT_PAGE =
|
||||||
|
process.env.NEXT_PUBLIC_HIDE_ABOUT_PAGE !== '1';
|
||||||
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
||||||
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
||||||
export const SHOW_EXIF_DATA =
|
export const SHOW_EXIF_DATA =
|
||||||
@ -442,8 +445,8 @@ export const APP_CONFIGURATION = {
|
|||||||
hasNavTitle: Boolean(CUSTOM_NAV_TITLE),
|
hasNavTitle: Boolean(CUSTOM_NAV_TITLE),
|
||||||
navCaption: NAV_CAPTION,
|
navCaption: NAV_CAPTION,
|
||||||
hasNavCaption: Boolean(NAV_CAPTION),
|
hasNavCaption: Boolean(NAV_CAPTION),
|
||||||
pageAbout: PAGE_ABOUT,
|
sidebarText: SIDEBAR_TEXT,
|
||||||
hasPageAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT),
|
hasSidebarText: Boolean(SIDEBAR_TEXT),
|
||||||
// Performance
|
// Performance
|
||||||
isStaticallyOptimized: HAS_STATIC_OPTIMIZATION,
|
isStaticallyOptimized: HAS_STATIC_OPTIMIZATION,
|
||||||
arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS,
|
arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS,
|
||||||
@ -489,6 +492,7 @@ export const APP_CONFIGURATION = {
|
|||||||
colorSortChromaCutoff: COLOR_SORT_CHROMA_CUTOFF,
|
colorSortChromaCutoff: COLOR_SORT_CHROMA_CUTOFF,
|
||||||
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
|
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
|
||||||
// Display
|
// Display
|
||||||
|
showAboutPage: SHOW_ABOUT_PAGE,
|
||||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
showExifInfo: SHOW_EXIF_DATA,
|
showExifInfo: SHOW_EXIF_DATA,
|
||||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||||
@ -552,7 +556,10 @@ const ALL_DEPRECATED_ENV_VARS = [{
|
|||||||
replacement: 'NEXT_PUBLIC_META_TITLE',
|
replacement: 'NEXT_PUBLIC_META_TITLE',
|
||||||
}, {
|
}, {
|
||||||
old: 'NEXT_PUBLIC_SITE_ABOUT',
|
old: 'NEXT_PUBLIC_SITE_ABOUT',
|
||||||
replacement: 'NEXT_PUBLIC_PAGE_ABOUT',
|
replacement: 'NEXT_PUBLIC_SIDEBAR_TEXT',
|
||||||
|
}, {
|
||||||
|
old: 'NEXT_PUBLIC_PAGE_ABOUT',
|
||||||
|
replacement: 'NEXT_PUBLIC_SIDEBAR_TEXT',
|
||||||
}, {
|
}, {
|
||||||
old: 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES',
|
old: 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES',
|
||||||
replacement: 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
|
replacement: 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { AlbumOrAlbumSlug } from '@/album';
|
|||||||
export const PATH_ROOT = '/';
|
export const PATH_ROOT = '/';
|
||||||
export const PATH_GRID = '/grid';
|
export const PATH_GRID = '/grid';
|
||||||
export const PATH_FULL = '/full';
|
export const PATH_FULL = '/full';
|
||||||
|
export const PATH_ABOUT = '/about';
|
||||||
export const PATH_ADMIN = '/admin';
|
export const PATH_ADMIN = '/admin';
|
||||||
export const PATH_API = '/api';
|
export const PATH_API = '/api';
|
||||||
export const PATH_SIGN_IN = '/sign-in';
|
export const PATH_SIGN_IN = '/sign-in';
|
||||||
@ -24,6 +25,10 @@ export const PATH_FULL_INFERRED = GRID_HOMEPAGE_ENABLED
|
|||||||
? PATH_FULL
|
? PATH_FULL
|
||||||
: PATH_ROOT;
|
: PATH_ROOT;
|
||||||
|
|
||||||
|
// Modifiers
|
||||||
|
const EDIT = 'edit';
|
||||||
|
const IMAGE = 'image';
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
export const PARAM_SORT_TYPE_TAKEN_AT = 'taken-at';
|
export const PARAM_SORT_TYPE_TAKEN_AT = 'taken-at';
|
||||||
export const PARAM_SORT_TYPE_UPLOADED_AT = 'uploaded-at';
|
export const PARAM_SORT_TYPE_UPLOADED_AT = 'uploaded-at';
|
||||||
@ -73,6 +78,7 @@ export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
|
|||||||
export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`;
|
export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`;
|
||||||
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
||||||
export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`;
|
export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`;
|
||||||
|
export const PATH_ADMIN_ABOUT_EDIT = `${PATH_ABOUT}/${EDIT}`;
|
||||||
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
|
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
|
||||||
export const PATH_ADMIN_COMPONENTS = `${PATH_ADMIN}/components`;
|
export const PATH_ADMIN_COMPONENTS = `${PATH_ADMIN}/components`;
|
||||||
|
|
||||||
@ -85,10 +91,6 @@ export const PATH_API_STORAGE = `${PATH_API}/storage`;
|
|||||||
export const PATH_API_VERCEL_BLOB_UPLOAD = `${PATH_API_STORAGE}/vercel-blob`;
|
export const PATH_API_VERCEL_BLOB_UPLOAD = `${PATH_API_STORAGE}/vercel-blob`;
|
||||||
export const PATH_API_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`;
|
export const PATH_API_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`;
|
||||||
|
|
||||||
// Modifiers
|
|
||||||
const EDIT = 'edit';
|
|
||||||
const IMAGE = 'image';
|
|
||||||
|
|
||||||
// Parameters
|
// Parameters
|
||||||
export const PARAM_UPLOAD_TITLE = 'title';
|
export const PARAM_UPLOAD_TITLE = 'title';
|
||||||
export const PARAM_SELECT = 'select';
|
export const PARAM_SELECT = 'select';
|
||||||
@ -107,6 +109,7 @@ export const PATHS_ADMIN = [
|
|||||||
PATH_ADMIN_RECIPES,
|
PATH_ADMIN_RECIPES,
|
||||||
PATH_ADMIN_INSIGHTS,
|
PATH_ADMIN_INSIGHTS,
|
||||||
PATH_ADMIN_CONFIGURATION,
|
PATH_ADMIN_CONFIGURATION,
|
||||||
|
PATH_ADMIN_ABOUT_EDIT,
|
||||||
PATH_ADMIN_BASELINE,
|
PATH_ADMIN_BASELINE,
|
||||||
PATH_ADMIN_COMPONENTS,
|
PATH_ADMIN_COMPONENTS,
|
||||||
];
|
];
|
||||||
@ -115,6 +118,7 @@ export const PATHS_TO_CACHE = [
|
|||||||
PATH_ROOT,
|
PATH_ROOT,
|
||||||
PATH_GRID,
|
PATH_GRID,
|
||||||
PATH_FULL,
|
PATH_FULL,
|
||||||
|
PATH_ABOUT,
|
||||||
PATH_OG,
|
PATH_OG,
|
||||||
PATH_PHOTO_DYNAMIC,
|
PATH_PHOTO_DYNAMIC,
|
||||||
PATH_CAMERA_DYNAMIC,
|
PATH_CAMERA_DYNAMIC,
|
||||||
@ -430,10 +434,14 @@ export const isPathGrid = (pathname?: string) =>
|
|||||||
export const isPathFull = (pathname?: string) =>
|
export const isPathFull = (pathname?: string) =>
|
||||||
checkPathPrefix(pathname, PATH_FULL);
|
checkPathPrefix(pathname, PATH_FULL);
|
||||||
|
|
||||||
|
export const isPathAbout = (pathname?: string) =>
|
||||||
|
checkPathPrefix(pathname, PATH_ABOUT);
|
||||||
|
|
||||||
export const isPathTopLevel = (pathname?: string) =>
|
export const isPathTopLevel = (pathname?: string) =>
|
||||||
isPathRoot(pathname)||
|
isPathRoot(pathname) ||
|
||||||
isPathGrid(pathname) ||
|
isPathGrid(pathname) ||
|
||||||
isPathFull(pathname);
|
isPathFull(pathname) ||
|
||||||
|
isPathAbout(pathname);
|
||||||
|
|
||||||
export const isPathSignIn = (pathname?: string) =>
|
export const isPathSignIn = (pathname?: string) =>
|
||||||
checkPathPrefix(pathname, PATH_SIGN_IN);
|
checkPathPrefix(pathname, PATH_SIGN_IN);
|
||||||
@ -460,6 +468,7 @@ export const isPathAdminInfo = (pathname?: string) =>
|
|||||||
export const isPathProtected = (pathname?: string) =>
|
export const isPathProtected = (pathname?: string) =>
|
||||||
checkPathPrefix(pathname, PATH_ADMIN) ||
|
checkPathPrefix(pathname, PATH_ADMIN) ||
|
||||||
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
|
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
|
||||||
|
checkPathPrefix(pathname, PATH_ADMIN_ABOUT_EDIT) ||
|
||||||
checkPathPrefix(pathname, PATH_OG);
|
checkPathPrefix(pathname, PATH_OG);
|
||||||
|
|
||||||
export const getPathComponents = (
|
export const getPathComponents = (
|
||||||
|
|||||||
36
src/cache/index.ts
vendored
36
src/cache/index.ts
vendored
@ -1,25 +1,39 @@
|
|||||||
import { PATHS_ADMIN, PATHS_TO_CACHE } from '@/app/path';
|
import { PATHS_ADMIN, PATHS_TO_CACHE } from '@/app/path';
|
||||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||||
|
|
||||||
|
// Page keys
|
||||||
|
export const KEY_ABOUT = 'about';
|
||||||
// Table key
|
// Table key
|
||||||
export const KEY_PHOTOS = 'photos';
|
export const KEY_PHOTOS = 'photos';
|
||||||
export const KEY_PHOTO = 'photo';
|
export const KEY_PHOTO = 'photo';
|
||||||
// Field keys
|
// Field keys
|
||||||
|
export const KEY_YEARS = 'years';
|
||||||
export const KEY_CAMERAS = 'cameras';
|
export const KEY_CAMERAS = 'cameras';
|
||||||
export const KEY_LENSES = 'lenses';
|
export const KEY_LENSES = 'lenses';
|
||||||
export const KEY_ALBUMS = 'albums';
|
export const KEY_ALBUMS = 'albums';
|
||||||
export const KEY_TAGS = 'tags';
|
export const KEY_TAGS = 'tags';
|
||||||
export const KEY_FILMS = 'films';
|
|
||||||
export const KEY_RECIPES = 'recipes';
|
export const KEY_RECIPES = 'recipes';
|
||||||
|
export const KEY_FILMS = 'films';
|
||||||
export const KEY_FOCAL_LENGTHS = 'focal-lengths';
|
export const KEY_FOCAL_LENGTHS = 'focal-lengths';
|
||||||
export const KEY_YEARS = 'years';
|
|
||||||
// Type keys
|
// Type keys
|
||||||
export const KEY_COUNT = 'count';
|
export const KEY_COUNT = 'count';
|
||||||
export const KEY_DATE_RANGE = 'date-range';
|
export const KEY_DATE_RANGE = 'date-range';
|
||||||
|
|
||||||
|
export const revalidateAboutKey = () =>
|
||||||
|
revalidateTag(KEY_ABOUT, 'max');
|
||||||
|
|
||||||
export const revalidatePhotosKey = () =>
|
export const revalidatePhotosKey = () =>
|
||||||
revalidateTag(KEY_PHOTOS, 'max');
|
revalidateTag(KEY_PHOTOS, 'max');
|
||||||
|
|
||||||
|
export const revalidateYearsKey = () =>
|
||||||
|
revalidateTag(KEY_YEARS, 'max');
|
||||||
|
|
||||||
|
export const revalidateCamerasKey = () =>
|
||||||
|
revalidateTag(KEY_CAMERAS, 'max');
|
||||||
|
|
||||||
|
export const revalidateLensesKey = () =>
|
||||||
|
revalidateTag(KEY_LENSES, 'max');
|
||||||
|
|
||||||
export const revalidateAlbumsKey = () =>
|
export const revalidateAlbumsKey = () =>
|
||||||
revalidateTag(KEY_ALBUMS, 'max');
|
revalidateTag(KEY_ALBUMS, 'max');
|
||||||
|
|
||||||
@ -29,31 +43,23 @@ export const revalidateTagsKey = () =>
|
|||||||
export const revalidateRecipesKey = () =>
|
export const revalidateRecipesKey = () =>
|
||||||
revalidateTag(KEY_RECIPES, 'max');
|
revalidateTag(KEY_RECIPES, 'max');
|
||||||
|
|
||||||
export const revalidateCamerasKey = () =>
|
|
||||||
revalidateTag(KEY_CAMERAS, 'max');
|
|
||||||
|
|
||||||
export const revalidateLensesKey = () =>
|
|
||||||
revalidateTag(KEY_LENSES, 'max');
|
|
||||||
|
|
||||||
export const revalidateFilmsKey = () =>
|
export const revalidateFilmsKey = () =>
|
||||||
revalidateTag(KEY_FILMS, 'max');
|
revalidateTag(KEY_FILMS, 'max');
|
||||||
|
|
||||||
export const revalidateFocalLengthsKey = () =>
|
export const revalidateFocalLengthsKey = () =>
|
||||||
revalidateTag(KEY_FOCAL_LENGTHS, 'max');
|
revalidateTag(KEY_FOCAL_LENGTHS, 'max');
|
||||||
|
|
||||||
export const revalidateYearsKey = () =>
|
|
||||||
revalidateTag(KEY_YEARS, 'max');
|
|
||||||
|
|
||||||
export const revalidateAllKeys = () => {
|
export const revalidateAllKeys = () => {
|
||||||
|
revalidateAboutKey();
|
||||||
revalidatePhotosKey();
|
revalidatePhotosKey();
|
||||||
revalidateAlbumsKey();
|
revalidateYearsKey();
|
||||||
revalidateTagsKey();
|
|
||||||
revalidateCamerasKey();
|
revalidateCamerasKey();
|
||||||
revalidateLensesKey();
|
revalidateLensesKey();
|
||||||
revalidateFilmsKey();
|
revalidateAlbumsKey();
|
||||||
|
revalidateTagsKey();
|
||||||
revalidateRecipesKey();
|
revalidateRecipesKey();
|
||||||
|
revalidateFilmsKey();
|
||||||
revalidateFocalLengthsKey();
|
revalidateFocalLengthsKey();
|
||||||
revalidateYearsKey();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const revalidateAdminPaths = () => {
|
export const revalidateAdminPaths = () => {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import { createLensKey } from '@/lens';
|
import { createLensKey } from '@/lens';
|
||||||
import { sortTagsByCount } from '@/tag';
|
import { sortTagsByCount } from '@/tag';
|
||||||
import { sortCategoriesByCount } from '@/category';
|
import { PhotoSetCategories, sortCategoriesByCount } from '@/category';
|
||||||
import { sortFocalLengths } from '@/focal';
|
import { sortFocalLengths } from '@/focal';
|
||||||
import {
|
import {
|
||||||
getPhotosMetaCached,
|
getPhotosMetaCached,
|
||||||
@ -160,3 +160,31 @@ export const getCountsForCategories = async () => {
|
|||||||
}, {} as Record<string, number>),
|
}, {} as Record<string, number>),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLastModifiedForCategories = (
|
||||||
|
{
|
||||||
|
recents,
|
||||||
|
years,
|
||||||
|
cameras,
|
||||||
|
lenses,
|
||||||
|
albums,
|
||||||
|
tags,
|
||||||
|
recipes,
|
||||||
|
films,
|
||||||
|
focalLengths,
|
||||||
|
}: PhotoSetCategories,
|
||||||
|
photos: { updatedAt: Date }[],
|
||||||
|
) => [
|
||||||
|
...recents.map(({ lastModified }) => lastModified),
|
||||||
|
...years.map(({ lastModified }) => lastModified),
|
||||||
|
...cameras.map(({ lastModified }) => lastModified),
|
||||||
|
...lenses.map(({ lastModified }) => lastModified),
|
||||||
|
...albums.map(({ lastModified }) => lastModified),
|
||||||
|
...tags.map(({ lastModified }) => lastModified),
|
||||||
|
...recipes.map(({ lastModified }) => lastModified),
|
||||||
|
...films.map(({ lastModified }) => lastModified),
|
||||||
|
...focalLengths.map(({ lastModified }) => lastModified),
|
||||||
|
...photos.map(({ updatedAt }) => updatedAt),
|
||||||
|
]
|
||||||
|
.filter(date => date instanceof Date)
|
||||||
|
.sort((a, b) => b.getTime() - a.getTime())[0];
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
useTransition,
|
useTransition,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
|
PATH_ABOUT,
|
||||||
PATH_ADMIN_BASELINE,
|
PATH_ADMIN_BASELINE,
|
||||||
PATH_ADMIN_COMPONENTS,
|
PATH_ADMIN_COMPONENTS,
|
||||||
PATH_ADMIN_CONFIGURATION,
|
PATH_ADMIN_CONFIGURATION,
|
||||||
@ -63,6 +64,7 @@ import {
|
|||||||
COLOR_SORT_ENABLED,
|
COLOR_SORT_ENABLED,
|
||||||
GRID_HOMEPAGE_ENABLED,
|
GRID_HOMEPAGE_ENABLED,
|
||||||
HIDE_TAGS_WITH_ONE_PHOTO,
|
HIDE_TAGS_WITH_ONE_PHOTO,
|
||||||
|
SHOW_ABOUT_PAGE,
|
||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
|
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
|
||||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
|
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
|
||||||
@ -611,6 +613,13 @@ export default function CommandKClient({
|
|||||||
? [pageGrid, pageFull]
|
? [pageGrid, pageFull]
|
||||||
: [pageFull, pageGrid];
|
: [pageFull, pageGrid];
|
||||||
|
|
||||||
|
if (SHOW_ABOUT_PAGE) {
|
||||||
|
pageItems.push({
|
||||||
|
label: appText.nav.about,
|
||||||
|
path: PATH_ABOUT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sectionPages: CommandKSection = {
|
const sectionPages: CommandKSection = {
|
||||||
heading: appText.cmdk.pages,
|
heading: appText.cmdk.pages,
|
||||||
accessory: <CgFileDocument size={14} className="translate-x-[-0.5px]" />,
|
accessory: <CgFileDocument size={14} className="translate-x-[-0.5px]" />,
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export default function Container({
|
|||||||
case 'gray-border': return [
|
case 'gray-border': return [
|
||||||
'text-medium',
|
'text-medium',
|
||||||
'bg-extra-dim',
|
'bg-extra-dim',
|
||||||
'border-medium',
|
'border border-medium',
|
||||||
];
|
];
|
||||||
case 'blue': return [
|
case 'blue': return [
|
||||||
'text-blue-800 dark:text-blue-400',
|
'text-blue-800 dark:text-blue-400',
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export default function HeaderList({
|
|||||||
'dark:text-gray-100',
|
'dark:text-gray-100',
|
||||||
'flex items-center mb-1 gap-1',
|
'flex items-center mb-1 gap-1',
|
||||||
'uppercase select-none',
|
'uppercase select-none',
|
||||||
|
'text-sm tracking-wide',
|
||||||
|
'translate-x-px',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon &&
|
{icon &&
|
||||||
@ -67,7 +69,7 @@ export default function HeaderList({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'mt-0.5',
|
'mt-0.5',
|
||||||
'text-xs font-medium tracking-wider',
|
'text-xs font-medium tracking-wider',
|
||||||
'border-medium rounded-md',
|
'border border-medium rounded-md',
|
||||||
'px-[5px] h-5!',
|
'px-[5px] h-5!',
|
||||||
'hover:bg-dim hover:text-main active:bg-main',
|
'hover:bg-dim hover:text-main active:bg-main',
|
||||||
'group',
|
'group',
|
||||||
|
|||||||
@ -13,18 +13,21 @@ import { useAppText } from '@/i18n/state/client';
|
|||||||
export default function ImageInput({
|
export default function ImageInput({
|
||||||
ref: inputRefExternal,
|
ref: inputRefExternal,
|
||||||
id = 'file',
|
id = 'file',
|
||||||
|
className,
|
||||||
onStart,
|
onStart,
|
||||||
onBlobReady,
|
onBlobReady,
|
||||||
multiple = true,
|
multiple = true,
|
||||||
shouldResize,
|
shouldResize,
|
||||||
maxSize = MAX_IMAGE_SIZE,
|
maxSize = MAX_IMAGE_SIZE,
|
||||||
quality = 0.9,
|
quality = 0.9,
|
||||||
|
hidden,
|
||||||
showButton,
|
showButton,
|
||||||
disabled: disabledProp,
|
disabled: disabledProp,
|
||||||
debug: _debug,
|
debug: _debug,
|
||||||
}: {
|
}: {
|
||||||
ref?: RefObject<HTMLInputElement | null>
|
ref?: RefObject<HTMLInputElement | null>
|
||||||
id?: string
|
id?: string
|
||||||
|
className?: string
|
||||||
onStart?: () => void
|
onStart?: () => void
|
||||||
onBlobReady?: (args: {
|
onBlobReady?: (args: {
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
@ -36,6 +39,7 @@ export default function ImageInput({
|
|||||||
shouldResize?: boolean
|
shouldResize?: boolean
|
||||||
maxSize?: number
|
maxSize?: number
|
||||||
quality?: number
|
quality?: number
|
||||||
|
hidden?: boolean
|
||||||
showButton?: boolean
|
showButton?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
@ -59,7 +63,10 @@ export default function ImageInput({
|
|||||||
const disabled = disabledProp || isUploading;
|
const disabled = disabledProp || isUploading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 min-w-0">
|
<div className={clsx(
|
||||||
|
hidden ? 'hidden' : 'flex flex-col gap-4 min-w-0',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
<label
|
<label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
|
|||||||
28
src/components/icons/IconAbout.tsx
Normal file
28
src/components/icons/IconAbout.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/* eslint-disable max-len */
|
||||||
|
|
||||||
|
const INTRINSIC_WIDTH = 28;
|
||||||
|
const INTRINSIC_HEIGHT = 24;
|
||||||
|
|
||||||
|
export default function IconAbout({
|
||||||
|
width = INTRINSIC_WIDTH,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
width?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={INTRINSIC_HEIGHT * width / INTRINSIC_WIDTH}
|
||||||
|
viewBox="0 0 28 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M14 12.75C14.5967 12.75 15.169 12.5129 15.591 12.091C16.0129 11.669 16.25 11.0967 16.25 10.5C16.25 9.90326 16.0129 9.33097 15.591 8.90901C15.169 8.48705 14.5967 8.25 14 8.25C13.4033 8.25 12.831 8.48705 12.409 8.90901C11.9871 9.33097 11.75 9.90326 11.75 10.5C11.75 11.0967 11.9871 11.669 12.409 12.091C12.831 12.5129 13.4033 12.75 14 12.75Z" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M14 5.25C19.4 5.25 20.75 6.6 20.75 12C20.75 17.4 19.4 18.75 14 18.75C8.6 18.75 7.25 17.4 7.25 12C7.25 6.6 8.6 5.25 14 5.25Z" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M9.5 18.0375V18C9.5 17.2043 9.81607 16.4413 10.3787 15.8787C10.9413 15.3161 11.7044 15 12.5 15H15.5C16.2956 15 17.0587 15.3161 17.6213 15.8787C18.1839 16.4413 18.5 17.2043 18.5 18V18.0375" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,11 +5,9 @@ const INTRINSIC_HEIGHT = 24;
|
|||||||
|
|
||||||
export default function IconFull({
|
export default function IconFull({
|
||||||
width = INTRINSIC_WIDTH,
|
width = INTRINSIC_WIDTH,
|
||||||
includeTitle = true,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
width?: number
|
width?: number
|
||||||
includeTitle?: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -22,10 +20,9 @@ export default function IconFull({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{includeTitle && <title>Full Frame</title>}
|
<path d="M8 7.625H20C20.7594 7.625 21.375 8.24061 21.375 9V15C21.375 15.7594 20.7594 16.375 20 16.375H8C7.24061 16.375 6.625 15.7594 6.625 15V9C6.625 8.24061 7.24061 7.625 8 7.625Z" strokeWidth="1.25"/>
|
||||||
<path d="M6.83301 7.125H21.167C21.5579 7.12518 21.8748 7.44206 21.875 7.83301V16.167C21.8748 16.5579 21.5579 16.8748 21.167 16.875H6.83301C6.44206 16.8748 6.12518 16.5579 6.125 16.167V7.83301C6.12518 7.44206 6.44206 7.12518 6.83301 7.125Z" strokeWidth="1.25"/>
|
<path d="M6 5.38H22" strokeWidth="1.25"/>
|
||||||
<path d="M5.5 4.875H22.5" strokeWidth="1.25"/>
|
<path d="M22 18.62L6 18.62" strokeWidth="1.25"/>
|
||||||
<path d="M22.5 19.125L5.5 19.125" strokeWidth="1.25"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,11 +5,9 @@ const INTRINSIC_HEIGHT = 24;
|
|||||||
|
|
||||||
export default function IconGrid({
|
export default function IconGrid({
|
||||||
width = INTRINSIC_WIDTH,
|
width = INTRINSIC_WIDTH,
|
||||||
includeTitle = true,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
width?: number
|
width?: number
|
||||||
includeTitle?: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -22,8 +20,7 @@ export default function IconGrid({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{includeTitle && <title>Grid</title>}
|
<rect x="5.625" y="6.625" width="16.75" height="10.75" rx="2.375" strokeWidth="1.25"/>
|
||||||
<rect x="5.625" y="6.625" width="16.75" height="10.75" rx="1" strokeWidth="1.25"/>
|
|
||||||
<line x1="11.375" y1="7" x2="11.375" y2="18" strokeWidth="1.25"/>
|
<line x1="11.375" y1="7" x2="11.375" y2="18" strokeWidth="1.25"/>
|
||||||
<line x1="16.875" y1="7" x2="16.875" y2="18" strokeWidth="1.25"/>
|
<line x1="16.875" y1="7" x2="16.875" y2="18" strokeWidth="1.25"/>
|
||||||
<line x1="5" y1="12.0417" x2="22.3333" y2="12.0417" strokeWidth="1.25"/>
|
<line x1="5" y1="12.0417" x2="22.3333" y2="12.0417" strokeWidth="1.25"/>
|
||||||
|
|||||||
@ -3,10 +3,8 @@ const INTRINSIC_HEIGHT = 24;
|
|||||||
|
|
||||||
export default function IconSearch({
|
export default function IconSearch({
|
||||||
width = INTRINSIC_WIDTH,
|
width = INTRINSIC_WIDTH,
|
||||||
includeTitle = true,
|
|
||||||
}: {
|
}: {
|
||||||
width?: number;
|
width?: number;
|
||||||
includeTitle?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@ -17,7 +15,6 @@ export default function IconSearch({
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
{includeTitle && <title>Search ⌘K</title>}
|
|
||||||
<circle cx="13.5" cy="11.5" r="4.875" strokeWidth="1.5" />
|
<circle cx="13.5" cy="11.5" r="4.875" strokeWidth="1.5" />
|
||||||
<path d="M17 15L21 19" strokeWidth="1.5" strokeLinecap="round" />
|
<path d="M17 15L21 19" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export default function OGTile({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'group',
|
'group',
|
||||||
'block w-full rounded-md overflow-hidden',
|
'block w-full rounded-md overflow-hidden',
|
||||||
'border-medium shadow-xs',
|
'border border-medium shadow-xs',
|
||||||
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
|
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -152,9 +152,9 @@ export default function SharedHoverProvider({
|
|||||||
{/* Border */}
|
{/* Border */}
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'absolute inset-0',
|
'absolute inset-0',
|
||||||
'rounded-[0.25rem]',
|
'border rounded-[0.25rem]',
|
||||||
hoverProps.color === 'frosted'
|
hoverProps.color === 'frosted'
|
||||||
? 'border border-gray-400/25'
|
? 'border-gray-400/25'
|
||||||
: 'border-medium',
|
: 'border-medium',
|
||||||
)} />
|
)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export default function Switcher({
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex divide-x overflow-hidden',
|
'flex divide-x overflow-hidden',
|
||||||
'rounded-[7px]',
|
'rounded-lg',
|
||||||
'divide-medium',
|
'divide-medium',
|
||||||
type === 'regular' &&
|
type === 'regular' &&
|
||||||
'outline-medium shadow-[0_2px_4px_rgba(0,0,0,0.07)]',
|
'outline-medium shadow-[0_2px_4px_rgba(0,0,0,0.07)]',
|
||||||
|
|||||||
@ -5,8 +5,11 @@ import Spinner from '../Spinner';
|
|||||||
import LinkWithIconLoader from '../LinkWithIconLoader';
|
import LinkWithIconLoader from '../LinkWithIconLoader';
|
||||||
import Tooltip from '../Tooltip';
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
const WIDTH_CLASS = 'w-[42px]';
|
export const SWITCHER_ITEM_WIDTH = 46;
|
||||||
const WIDTH_CLASS_NARROW = 'w-[36px]';
|
|
||||||
|
export const WIDTH_CLASS = 'w-[46px]';
|
||||||
|
export const WIDTH_CLASS_NARROW = 'w-[36px]';
|
||||||
|
export const HEIGHT_CLASS = 'h-[32px]';
|
||||||
|
|
||||||
export default function SwitcherItem({
|
export default function SwitcherItem({
|
||||||
icon,
|
icon,
|
||||||
@ -38,7 +41,7 @@ export default function SwitcherItem({
|
|||||||
const widthClass = width === 'narrow' ? WIDTH_CLASS_NARROW : WIDTH_CLASS;
|
const widthClass = width === 'narrow' ? WIDTH_CLASS_NARROW : WIDTH_CLASS;
|
||||||
const className = clsx(
|
const className = clsx(
|
||||||
'flex items-center justify-center',
|
'flex items-center justify-center',
|
||||||
`${widthClass} h-[30px]`,
|
`${widthClass} ${HEIGHT_CLASS}`,
|
||||||
isInteractive && 'cursor-pointer',
|
isInteractive && 'cursor-pointer',
|
||||||
isInteractive && 'hover:bg-gray-100/60 active:bg-gray-100',
|
isInteractive && 'hover:bg-gray-100/60 active:bg-gray-100',
|
||||||
isInteractive && 'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
|
isInteractive && 'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { createPhotosTable } from '@/photo/query';
|
|||||||
import sleep from '@/utility/sleep';
|
import sleep from '@/utility/sleep';
|
||||||
import { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config';
|
import { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config';
|
||||||
import { createAlbumPhotoTable, createAlbumsTable } from '@/album/query';
|
import { createAlbumPhotoTable, createAlbumsTable } from '@/album/query';
|
||||||
|
import { createAboutTable } from '@/about/query';
|
||||||
|
|
||||||
// Safe wrapper intended for most queries with JIT migration/table creation
|
// Safe wrapper intended for most queries with JIT migration/table creation
|
||||||
// Catches up to 3 migrations in older installations
|
// Catches up to 3 migrations in older installations
|
||||||
@ -54,6 +55,7 @@ export const safelyQuery = async <T>(
|
|||||||
await createPhotosTable();
|
await createPhotosTable();
|
||||||
await createAlbumsTable();
|
await createAlbumsTable();
|
||||||
await createAlbumPhotoTable();
|
await createAlbumPhotoTable();
|
||||||
|
await createAboutTable();
|
||||||
result = await callback();
|
result = await callback();
|
||||||
} else if (/relation "albums" does not exist/i.test(e.message)) {
|
} else if (/relation "albums" does not exist/i.test(e.message)) {
|
||||||
// Create albums tables if they don't exist
|
// Create albums tables if they don't exist
|
||||||
@ -61,6 +63,11 @@ export const safelyQuery = async <T>(
|
|||||||
await createAlbumsTable();
|
await createAlbumsTable();
|
||||||
await createAlbumPhotoTable();
|
await createAlbumPhotoTable();
|
||||||
result = await callback();
|
result = await callback();
|
||||||
|
} else if (/relation "about" does not exist/i.test(e.message)) {
|
||||||
|
// Create about table if it doesn't exist
|
||||||
|
console.log('Creating about table ...');
|
||||||
|
await createAboutTable();
|
||||||
|
result = await callback();
|
||||||
} else if (/endpoint is in transition/i.test(e.message)) {
|
} else if (/endpoint is in transition/i.test(e.message)) {
|
||||||
console.log(
|
console.log(
|
||||||
'SQL query error: endpoint is in transition (setting timeout)',
|
'SQL query error: endpoint is in transition (setting timeout)',
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'হোম',
|
home: 'হোম',
|
||||||
full: 'সম্পূর্ণ',
|
full: 'সম্পূর্ণ',
|
||||||
grid: 'গ্রিড',
|
grid: 'গ্রিড',
|
||||||
|
about: 'সম্পর্কে',
|
||||||
admin: 'অ্যাডমিন',
|
admin: 'অ্যাডমিন',
|
||||||
search: 'সার্চ',
|
search: 'সার্চ',
|
||||||
prev: 'পূর্ববর্তী',
|
prev: 'পূর্ববর্তী',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'পরবর্তী',
|
next: 'পরবর্তী',
|
||||||
nextShort: 'পরবর্তী',
|
nextShort: 'পরবর্তী',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'এই সাইট সম্পর্কে',
|
||||||
|
updated: '{{distance}} আগে আপডেট হয়েছে',
|
||||||
|
photoCount: 'ছবির সংখ্যা',
|
||||||
|
firstPhoto: 'প্রথম ছবি',
|
||||||
|
topCamera: 'শীর্ষ ক্যামেরা',
|
||||||
|
topLens: 'শীর্ষ লেন্স',
|
||||||
|
topRecipe: 'শীর্ষ রেসিপি',
|
||||||
|
topFilm: 'শীর্ষ ফিল্ম',
|
||||||
|
recentAlbum: 'সাম্প্রতিক অ্যালবাম',
|
||||||
|
popularTag: 'জনপ্রিয় ট্যাগ',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'তৈরি হয়েছে',
|
madeWith: 'তৈরি হয়েছে',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'Home',
|
home: 'Home',
|
||||||
full: 'Full',
|
full: 'Full',
|
||||||
grid: 'Grid',
|
grid: 'Grid',
|
||||||
|
about: 'About',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
prev: 'Previous',
|
prev: 'Previous',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'Next',
|
next: 'Next',
|
||||||
nextShort: 'Next',
|
nextShort: 'Next',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'About this site',
|
||||||
|
updated: 'Updated {{distance}} ago',
|
||||||
|
photoCount: 'Photo Count',
|
||||||
|
firstPhoto: 'First Photo',
|
||||||
|
topCamera: 'Top Camera',
|
||||||
|
topLens: 'Top Lens',
|
||||||
|
topRecipe: 'Top Recipe',
|
||||||
|
topFilm: 'Top Film',
|
||||||
|
recentAlbum: 'Recent Album',
|
||||||
|
popularTag: 'Popular Tag',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Made with',
|
madeWith: 'Made with',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export const TEXT = {
|
|||||||
home: 'Home',
|
home: 'Home',
|
||||||
full: 'Full',
|
full: 'Full',
|
||||||
grid: 'Grid',
|
grid: 'Grid',
|
||||||
|
about: 'About',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
prev: 'Previous',
|
prev: 'Previous',
|
||||||
@ -54,6 +55,18 @@ export const TEXT = {
|
|||||||
next: 'Next',
|
next: 'Next',
|
||||||
nextShort: 'Next',
|
nextShort: 'Next',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'About this site',
|
||||||
|
updated: 'Updated {{distance}} ago',
|
||||||
|
photoCount: 'Photo Count',
|
||||||
|
firstPhoto: 'First Photo',
|
||||||
|
topCamera: 'Top Camera',
|
||||||
|
topLens: 'Top Lens',
|
||||||
|
topRecipe: 'Top Recipe',
|
||||||
|
topFilm: 'Top Film',
|
||||||
|
recentAlbum: 'Recent Album',
|
||||||
|
popularTag: 'Popular Tag',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Made with',
|
madeWith: 'Made with',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'होम',
|
home: 'होम',
|
||||||
full: 'पूर्ण',
|
full: 'पूर्ण',
|
||||||
grid: 'ग्रिड',
|
grid: 'ग्रिड',
|
||||||
|
about: 'के बारे में',
|
||||||
admin: 'एडमिन',
|
admin: 'एडमिन',
|
||||||
search: 'खोज',
|
search: 'खोज',
|
||||||
prev: 'पिछला',
|
prev: 'पिछला',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'अगला',
|
next: 'अगला',
|
||||||
nextShort: 'अगला',
|
nextShort: 'अगला',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'इस साइट के बारे में',
|
||||||
|
updated: '{{distance}} पहले अपडेट किया गया',
|
||||||
|
photoCount: 'फोटो की संख्या',
|
||||||
|
firstPhoto: 'पहली फोटो',
|
||||||
|
topCamera: 'शीर्ष कैमरा',
|
||||||
|
topLens: 'शीर्ष लेंस',
|
||||||
|
topRecipe: 'शीर्ष रेसिपी',
|
||||||
|
topFilm: 'शीर्ष फिल्म',
|
||||||
|
recentAlbum: 'हाल का एल्बम',
|
||||||
|
popularTag: 'लोकप्रिय टैग',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'निर्मित',
|
madeWith: 'निर्मित',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'Beranda',
|
home: 'Beranda',
|
||||||
full: 'Lengkap',
|
full: 'Lengkap',
|
||||||
grid: 'Grid',
|
grid: 'Grid',
|
||||||
|
about: 'Tentang',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
search: 'Cari',
|
search: 'Cari',
|
||||||
prev: 'Sebelumnya',
|
prev: 'Sebelumnya',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'Berikutnya',
|
next: 'Berikutnya',
|
||||||
nextShort: 'Brkt',
|
nextShort: 'Brkt',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'Tentang situs ini',
|
||||||
|
updated: 'Diperbarui {{distance}} yang lalu',
|
||||||
|
photoCount: 'Jumlah foto',
|
||||||
|
firstPhoto: 'Foto pertama',
|
||||||
|
topCamera: 'Kamera teratas',
|
||||||
|
topLens: 'Lensa teratas',
|
||||||
|
topRecipe: 'Resep teratas',
|
||||||
|
topFilm: 'Film teratas',
|
||||||
|
recentAlbum: 'Album terbaru',
|
||||||
|
popularTag: 'Tag populer',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Dibuat dengan',
|
madeWith: 'Dibuat dengan',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'Início',
|
home: 'Início',
|
||||||
full: 'Completo',
|
full: 'Completo',
|
||||||
grid: 'Grade',
|
grid: 'Grade',
|
||||||
|
about: 'Sobre',
|
||||||
admin: 'Menu de administrador',
|
admin: 'Menu de administrador',
|
||||||
search: 'Pesquisar',
|
search: 'Pesquisar',
|
||||||
prev: 'Anterior',
|
prev: 'Anterior',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'Próximo',
|
next: 'Próximo',
|
||||||
nextShort: 'Próx',
|
nextShort: 'Próx',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'Sobre este site',
|
||||||
|
updated: 'Atualizado há {{distance}}',
|
||||||
|
photoCount: 'Quantidade de fotos',
|
||||||
|
firstPhoto: 'Primeira foto',
|
||||||
|
topCamera: 'Câmera principal',
|
||||||
|
topLens: 'Lente principal',
|
||||||
|
topRecipe: 'Receita principal',
|
||||||
|
topFilm: 'Filme principal',
|
||||||
|
recentAlbum: 'Álbum recente',
|
||||||
|
popularTag: 'Tag popular',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Feito com',
|
madeWith: 'Feito com',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'Início',
|
home: 'Início',
|
||||||
full: 'Completo',
|
full: 'Completo',
|
||||||
grid: 'Grade',
|
grid: 'Grade',
|
||||||
|
about: 'Sobre',
|
||||||
admin: 'Menu de administração',
|
admin: 'Menu de administração',
|
||||||
search: 'Pesquisar',
|
search: 'Pesquisar',
|
||||||
prev: 'Anterior',
|
prev: 'Anterior',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'Próximo',
|
next: 'Próximo',
|
||||||
nextShort: 'Próx',
|
nextShort: 'Próx',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'Sobre este sítio',
|
||||||
|
updated: 'Atualizado há {{distance}}',
|
||||||
|
photoCount: 'Número de fotos',
|
||||||
|
firstPhoto: 'Primeira foto',
|
||||||
|
topCamera: 'Câmara principal',
|
||||||
|
topLens: 'Objetiva principal',
|
||||||
|
topRecipe: 'Receita principal',
|
||||||
|
topFilm: 'Filme principal',
|
||||||
|
recentAlbum: 'Álbum recente',
|
||||||
|
popularTag: 'Etiqueta popular',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Feito com',
|
madeWith: 'Feito com',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'Anasayfa',
|
home: 'Anasayfa',
|
||||||
full: 'Tam',
|
full: 'Tam',
|
||||||
grid: 'Izgara',
|
grid: 'Izgara',
|
||||||
|
about: 'Hakkında',
|
||||||
admin: 'Yönetici',
|
admin: 'Yönetici',
|
||||||
search: 'Ara',
|
search: 'Ara',
|
||||||
prev: 'Önceki',
|
prev: 'Önceki',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'Sonraki',
|
next: 'Sonraki',
|
||||||
nextShort: 'Sonraki',
|
nextShort: 'Sonraki',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'Site hakkında',
|
||||||
|
updated: '{{distance}} önce güncellendi',
|
||||||
|
photoCount: 'Fotoğraf sayısı',
|
||||||
|
firstPhoto: 'İlk fotoğraf',
|
||||||
|
topCamera: 'En çok kullanılan kamera',
|
||||||
|
topLens: 'En çok kullanılan lens',
|
||||||
|
topRecipe: 'En çok kullanılan tarif',
|
||||||
|
topFilm: 'En çok kullanılan film',
|
||||||
|
recentAlbum: 'Son albüm',
|
||||||
|
popularTag: 'Popüler etiket',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Hazırlayan:',
|
madeWith: 'Hazırlayan:',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: 'Trang chủ',
|
home: 'Trang chủ',
|
||||||
full: 'Toàn bộ',
|
full: 'Toàn bộ',
|
||||||
grid: 'Lưới',
|
grid: 'Lưới',
|
||||||
|
about: 'Giới thiệu',
|
||||||
admin: 'Quản trị',
|
admin: 'Quản trị',
|
||||||
search: 'Tìm kiếm',
|
search: 'Tìm kiếm',
|
||||||
prev: 'Trước',
|
prev: 'Trước',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: 'Tiếp',
|
next: 'Tiếp',
|
||||||
nextShort: 'Tiếp',
|
nextShort: 'Tiếp',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: 'Giới thiệu trang web',
|
||||||
|
updated: 'Cập nhật {{distance}} trước',
|
||||||
|
photoCount: 'Số lượng ảnh',
|
||||||
|
firstPhoto: 'Ảnh đầu tiên',
|
||||||
|
topCamera: 'Máy ảnh phổ biến',
|
||||||
|
topLens: 'Ống kính phổ biến',
|
||||||
|
topRecipe: 'Công thức phổ biến',
|
||||||
|
topFilm: 'Phim phổ biến',
|
||||||
|
recentAlbum: 'Album gần đây',
|
||||||
|
popularTag: 'Thẻ phổ biến',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: 'Được tạo bằng',
|
madeWith: 'Được tạo bằng',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
|||||||
home: '首页',
|
home: '首页',
|
||||||
full: '完整',
|
full: '完整',
|
||||||
grid: '网格',
|
grid: '网格',
|
||||||
|
about: '关于',
|
||||||
admin: '管理',
|
admin: '管理',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
prev: '上一页',
|
prev: '上一页',
|
||||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
|||||||
next: '下一页',
|
next: '下一页',
|
||||||
nextShort: '下一页',
|
nextShort: '下一页',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
titleDefault: '关于本网站',
|
||||||
|
updated: '{{distance}} 前更新',
|
||||||
|
photoCount: '照片数量',
|
||||||
|
firstPhoto: '第一张照片',
|
||||||
|
topCamera: '常用相机',
|
||||||
|
topLens: '常用镜头',
|
||||||
|
topRecipe: '常用配方',
|
||||||
|
topFilm: '常用胶片',
|
||||||
|
recentAlbum: '最近相册',
|
||||||
|
popularTag: '热门标签',
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
madeWith: '基于',
|
madeWith: '基于',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,6 +28,11 @@ export const generateAppTextState = (i18n: I18N) => {
|
|||||||
recentSubhead: (distance: string) =>
|
recentSubhead: (distance: string) =>
|
||||||
i18n.category.recentSubhead.replace('{{distance}}', distance),
|
i18n.category.recentSubhead.replace('{{distance}}', distance),
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
...i18n.about,
|
||||||
|
updated: (distance: string) =>
|
||||||
|
i18n.about.updated.replace('{{distance}}', distance),
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
...i18n.admin,
|
...i18n.admin,
|
||||||
deleteConfirm: (photoTitle: string) =>
|
deleteConfirm: (photoTitle: string) =>
|
||||||
|
|||||||
38
src/photo/PhotoAvatar.tsx
Normal file
38
src/photo/PhotoAvatar.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import ImageMedium from '@/components/image/ImageMedium';
|
||||||
|
import { altTextForPhoto, Photo } from '.';
|
||||||
|
import clsx from 'clsx/lite';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export default function PhotoAvatar({
|
||||||
|
photo,
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
photo?: Photo
|
||||||
|
className?: string
|
||||||
|
placeholder?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(
|
||||||
|
'inline-block',
|
||||||
|
'size-12 rounded-full overflow-auto',
|
||||||
|
'border border-medium bg-dim',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{photo
|
||||||
|
? <ImageMedium
|
||||||
|
src={photo.url}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
alt={altTextForPhoto(photo)}
|
||||||
|
blurDataURL={photo.blurData}
|
||||||
|
aspectRatio={photo.aspectRatio}
|
||||||
|
/>
|
||||||
|
: placeholder && <span className={clsx(
|
||||||
|
'w-full h-full',
|
||||||
|
'flex items-center justify-center',
|
||||||
|
)}>
|
||||||
|
{placeholder}
|
||||||
|
</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,16 +4,16 @@ import {
|
|||||||
htmlHasBrParagraphBreaks,
|
htmlHasBrParagraphBreaks,
|
||||||
safelyParseFormattedHtml,
|
safelyParseFormattedHtml,
|
||||||
} from '@/utility/html';
|
} from '@/utility/html';
|
||||||
import { PAGE_ABOUT } from '@/app/config';
|
import { SIDEBAR_TEXT } from '@/app/config';
|
||||||
|
|
||||||
export default function PhotoGridPage(
|
export default function PhotoGridPage(
|
||||||
props: ComponentProps<typeof PhotoGridPageClient>,
|
props: ComponentProps<typeof PhotoGridPageClient>,
|
||||||
) {
|
) {
|
||||||
const aboutTextSafelyParsedHtml = PAGE_ABOUT
|
const aboutTextSafelyParsedHtml = SIDEBAR_TEXT
|
||||||
? safelyParseFormattedHtml(PAGE_ABOUT)
|
? safelyParseFormattedHtml(SIDEBAR_TEXT)
|
||||||
: undefined;
|
: undefined;
|
||||||
const aboutTextHasBrParagraphBreaks = PAGE_ABOUT
|
const aboutTextHasBrParagraphBreaks = SIDEBAR_TEXT
|
||||||
? htmlHasBrParagraphBreaks(PAGE_ABOUT)
|
? htmlHasBrParagraphBreaks(SIDEBAR_TEXT)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return <PhotoGridPageClient {...{
|
return <PhotoGridPageClient {...{
|
||||||
|
|||||||
@ -763,6 +763,11 @@ export const batchPhotoAction = async ({
|
|||||||
revalidateAllKeysAndPaths();
|
revalidateAllKeysAndPaths();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getPhotoAction = async (photoId: string) =>
|
||||||
|
runAuthenticatedAdminServerAction(async () =>
|
||||||
|
getPhoto(photoId, true),
|
||||||
|
);
|
||||||
|
|
||||||
// Public/Private actions
|
// Public/Private actions
|
||||||
|
|
||||||
export const getPhotosAction = async (
|
export const getPhotosAction = async (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export const KEY_COMMANDS = {
|
export const KEY_COMMANDS = {
|
||||||
full: 'F',
|
full: 'F',
|
||||||
grid: 'G',
|
grid: 'G',
|
||||||
admin: 'A',
|
about: 'A',
|
||||||
prev: ['J', 'ARROWLEFT'],
|
prev: ['J', 'ARROWLEFT'],
|
||||||
next: ['L', 'ARROWRIGHT'],
|
next: ['L', 'ARROWRIGHT'],
|
||||||
edit: 'E',
|
edit: 'E',
|
||||||
|
|||||||
34
src/photo/useDynamicPhoto.ts
Normal file
34
src/photo/useDynamicPhoto.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getPhotoAction } from './actions';
|
||||||
|
import { Photo } from '.';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
export default function useDynamicPhoto({
|
||||||
|
initialPhoto,
|
||||||
|
photoId,
|
||||||
|
}: {
|
||||||
|
initialPhoto?: Photo
|
||||||
|
photoId?: string
|
||||||
|
}) {
|
||||||
|
const [photo, setPhoto] = useState(initialPhoto);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [photoIdDebounced] = useDebounce(photoId, 500);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (photoIdDebounced) {
|
||||||
|
if (photoIdDebounced !== photo?.id) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setIsLoading(true);
|
||||||
|
getPhotoAction(photoIdDebounced).then(setPhoto)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPhoto(undefined);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [photoIdDebounced, photo?.id]);
|
||||||
|
|
||||||
|
return { photo, isLoading };
|
||||||
|
}
|
||||||
@ -118,7 +118,7 @@ export default function ShareModal({
|
|||||||
'rounded-md',
|
'rounded-md',
|
||||||
'w-full overflow-hidden',
|
'w-full overflow-hidden',
|
||||||
'flex items-center justify-stretch',
|
'flex items-center justify-stretch',
|
||||||
'border-medium',
|
'border border-medium',
|
||||||
)}>
|
)}>
|
||||||
<MaskedScroll
|
<MaskedScroll
|
||||||
className="flex grow"
|
className="flex grow"
|
||||||
|
|||||||
@ -157,7 +157,7 @@ html {
|
|||||||
}
|
}
|
||||||
@utility border-medium {
|
@utility border-medium {
|
||||||
@apply
|
@apply
|
||||||
border border-gray-400/25 dark:border-gray-800
|
border-gray-400/25 dark:border-gray-800
|
||||||
}
|
}
|
||||||
@utility outline-medium {
|
@utility outline-medium {
|
||||||
@apply
|
@apply
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user