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_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_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)
|
||||
|
||||
### Performance
|
||||
@ -160,6 +160,7 @@ Create Upstash Redis store from storage tab of Vercel dashboard and link to your
|
||||
|
||||
|
||||
### 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_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
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
isPathTag,
|
||||
isPathTagPhoto,
|
||||
PATH_ADMIN,
|
||||
PATH_ADMIN_ABOUT_EDIT,
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_FULL,
|
||||
PATH_GRID,
|
||||
@ -91,11 +92,12 @@ describe('Paths', () => {
|
||||
// Private
|
||||
expect(isPathProtected(PATH_ADMIN)).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_ALL)).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', () => {
|
||||
// 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 { BASE_URL, GRID_HOMEPAGE_ENABLED } from '@/app/config';
|
||||
import { getAllPhotoIdsWithUpdatedAt } from '@/photo/query';
|
||||
import {
|
||||
getLastModifiedForCategories,
|
||||
NULL_CATEGORY_DATA,
|
||||
} from '@/category/data';
|
||||
|
||||
// Cache for 24 hours
|
||||
export const revalidate = 86_400;
|
||||
@ -29,47 +33,29 @@ const PRIORITY_PHOTO = 0.5;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const [
|
||||
{
|
||||
recents,
|
||||
years,
|
||||
cameras,
|
||||
lenses,
|
||||
albums,
|
||||
tags,
|
||||
recipes,
|
||||
films,
|
||||
focalLengths,
|
||||
},
|
||||
categories,
|
||||
photos,
|
||||
] = await Promise.all([
|
||||
getDataForCategoriesCached().catch(() => ({
|
||||
recents: [],
|
||||
years: [],
|
||||
cameras: [],
|
||||
lenses: [],
|
||||
albums: [],
|
||||
tags: [],
|
||||
recipes: [],
|
||||
films: [],
|
||||
focalLengths: [],
|
||||
})),
|
||||
getDataForCategoriesCached().catch(() => NULL_CATEGORY_DATA),
|
||||
getAllPhotoIdsWithUpdatedAt().catch(() => []),
|
||||
]);
|
||||
|
||||
const lastModifiedSite = [
|
||||
...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];
|
||||
const {
|
||||
recents,
|
||||
years,
|
||||
cameras,
|
||||
lenses,
|
||||
albums,
|
||||
tags,
|
||||
recipes,
|
||||
films,
|
||||
focalLengths,
|
||||
} = categories;
|
||||
|
||||
const lastModifiedSite = getLastModifiedForCategories(
|
||||
categories,
|
||||
photos,
|
||||
);
|
||||
|
||||
return [
|
||||
// Homepage
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
/* eslint-disable max-len */
|
||||
import type { Config } from 'jest';
|
||||
import nextJest from 'next/jest.js';
|
||||
|
||||
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: './',
|
||||
});
|
||||
|
||||
@ -14,5 +14,6 @@ const config: Config = {
|
||||
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);
|
||||
|
||||
@ -1 +1,4 @@
|
||||
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)/'",
|
||||
"analyze": "ANALYZE=true next build --webpack"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"packageManager": "pnpm@10.30.2",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.30",
|
||||
"@ai-sdk/rsc": "^2.0.97",
|
||||
"@aws-sdk/client-s3": "3.995.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.995.0",
|
||||
"@ai-sdk/openai": "^3.0.34",
|
||||
"@ai-sdk/rsc": "^2.0.100",
|
||||
"@aws-sdk/client-s3": "3.998.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.998.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@ -23,7 +23,7 @@
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/blob": "^2.3.0",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^6.0.97",
|
||||
"ai": "^6.0.100",
|
||||
"camelcase-keys": "^10.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@ -40,7 +40,7 @@
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"ol": "^10.8.0",
|
||||
"pg": "^8.18.0",
|
||||
"pg": "^8.19.0",
|
||||
"piexifjs": "^1.0.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@ -59,7 +59,7 @@
|
||||
"@next/bundle-analyzer": "16.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@ -78,7 +78,7 @@
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "4.2.0",
|
||||
"tailwindcss": "4.2.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"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/*
|
||||
// - /grid
|
||||
// - /full
|
||||
// - /about
|
||||
// - / (root)
|
||||
// - /home-image
|
||||
// - /template-image
|
||||
// - /template-image-tight
|
||||
// - /template-url
|
||||
// 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 { useSelectPhotosState } from './select/SelectPhotosState';
|
||||
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({
|
||||
isOpen,
|
||||
@ -220,23 +225,25 @@ export default function AdminAppMenu({
|
||||
return (
|
||||
<SwitcherItemMenu
|
||||
{...{ isOpen, setIsOpen }}
|
||||
icon={<div className="w-[28px] h-[28px] overflow-hidden">
|
||||
icon={<div className={`w-full ${HEIGHT_CLASS} overflow-hidden`}>
|
||||
<div className={clsx(
|
||||
'relative flex flex-col items-center justify-center gap-2',
|
||||
'translate-y-[-18px]',
|
||||
'relative flex flex-col items-center gap-2',
|
||||
'translate-y-[-16px]',
|
||||
)}>
|
||||
<IoArrowDown size={16} className="shrink-0" />
|
||||
<IoArrowUp size={16} className="shrink-0" />
|
||||
</div>
|
||||
</div>}
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
alignOffset={-84}
|
||||
sideOffset={10}
|
||||
alignOffset={SHOW_ABOUT_PAGE
|
||||
? -(SWITCHER_ITEM_WIDTH * 3)
|
||||
: -(SWITCHER_ITEM_WIDTH * 2)}
|
||||
onOpen={refreshAdminData}
|
||||
sections={sections}
|
||||
ariaLabel="Admin Menu"
|
||||
classNameButtonOpen={clsx(
|
||||
'[&>*>*]:translate-y-[6px]',
|
||||
'[&>*>*]:translate-y-[8px]',
|
||||
'[&>*>*]:duration-300',
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -5,16 +5,19 @@ import { IoInformationCircleOutline } from 'react-icons/io5';
|
||||
export default function AdminEmptyState({
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
includeContainer = true,
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
includeContainer?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'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(
|
||||
'size-14 flex justify-center items-center',
|
||||
|
||||
@ -78,7 +78,7 @@ export default function AdminUploadsTableRow({
|
||||
'flex items-center grow',
|
||||
'transition-opacity',
|
||||
'rounded-lg overflow-hidden',
|
||||
'border-medium bg-extra-dim',
|
||||
'border border-medium bg-extra-dim',
|
||||
isAdding && !isComplete && status !== 'adding' && 'opacity-30',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
} from '@/photo/ai';
|
||||
import clsx from 'clsx/lite';
|
||||
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 {
|
||||
AdminConfigSection,
|
||||
@ -68,8 +68,8 @@ export default function AdminAppConfigurationClient({
|
||||
hasNavTitle,
|
||||
navCaption,
|
||||
hasNavCaption,
|
||||
pageAbout,
|
||||
hasPageAbout,
|
||||
sidebarText,
|
||||
hasSidebarText,
|
||||
// Performance
|
||||
isStaticallyOptimized,
|
||||
arePhotosStaticallyOptimized,
|
||||
@ -106,6 +106,7 @@ export default function AdminAppConfigurationClient({
|
||||
colorSortChromaCutoff,
|
||||
isSortWithPriority,
|
||||
// Display
|
||||
showAboutPage,
|
||||
showKeyboardShortcutTooltips,
|
||||
showExifInfo,
|
||||
showZoomControls,
|
||||
@ -377,7 +378,7 @@ export default function AdminAppConfigurationClient({
|
||||
status={hasAuthSecret}
|
||||
isPending={!hasAuthSecret && isAnalyzingConfiguration}
|
||||
>
|
||||
Store auth secret in environment variable:
|
||||
Store auth secret in environment variable
|
||||
{!hasAuthSecret &&
|
||||
<div className="overflow-x-auto">
|
||||
<SecretGenerator {...{ secret }} />
|
||||
@ -390,7 +391,7 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
Store admin email/password
|
||||
{' '}
|
||||
in environment variables:
|
||||
in environment variables
|
||||
{renderEnvVars([
|
||||
'ADMIN_EMAIL',
|
||||
'ADMIN_PASSWORD',
|
||||
@ -405,8 +406,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
{renderContent(locale)}
|
||||
Store in environment variable
|
||||
(check README for
|
||||
Check README for
|
||||
{' '}
|
||||
<AdminLink
|
||||
// eslint-disable-next-line max-len
|
||||
@ -414,7 +414,6 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
supported languages
|
||||
</AdminLink>
|
||||
):
|
||||
{renderEnvVars(['NEXT_PUBLIC_LOCALE'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -422,8 +421,7 @@ export default function AdminAppConfigurationClient({
|
||||
status={hasDomain}
|
||||
>
|
||||
{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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -432,8 +430,7 @@ export default function AdminAppConfigurationClient({
|
||||
showWarning
|
||||
>
|
||||
{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'])}
|
||||
</ChecklistRow>
|
||||
{!simplifiedView && <>
|
||||
@ -443,8 +440,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
{renderContent(metaDescription)}
|
||||
Store in environment variable
|
||||
(seen in search results):
|
||||
Seen in search results
|
||||
{renderEnvVars(['NEXT_PUBLIC_META_DESCRIPTION'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -453,7 +449,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
{renderContent(navTitle)}
|
||||
Store in environment variable (replaces domain in top-right nav):
|
||||
Replaces domain in top-right nav
|
||||
{renderEnvVars(['NEXT_PUBLIC_NAV_TITLE'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -462,18 +458,17 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
{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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Page about"
|
||||
status={hasPageAbout}
|
||||
title="Sidebar text"
|
||||
status={hasSidebarText}
|
||||
optional
|
||||
>
|
||||
{hasPageAbout && renderContent(pageAbout)}
|
||||
Store in environment variable (seen in sidebar):
|
||||
{renderEnvVars(['NEXT_PUBLIC_PAGE_ABOUT'])}
|
||||
{hasSidebarText && renderContent(sidebarText)}
|
||||
Seen in sidebar on desktop grid view
|
||||
{renderEnvVars(['NEXT_PUBLIC_SIDEBAR_TEXT'])}
|
||||
</ChecklistRow>
|
||||
</>}
|
||||
</>;
|
||||
@ -510,7 +505,7 @@ export default function AdminAppConfigurationClient({
|
||||
text descriptions, including an invisible field called
|
||||
{' '}
|
||||
{'"Semantic Description"'}, which supports CMD-K search
|
||||
and image accessibility:
|
||||
and image accessibility
|
||||
{renderEnvVars(['OPENAI_SECRET_KEY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -525,7 +520,7 @@ export default function AdminAppConfigurationClient({
|
||||
connection: { provider: 'Google Places', error: locationError},
|
||||
})}
|
||||
Store Google Places API key in order to add location meta
|
||||
to entities like albums:
|
||||
to entities like albums
|
||||
{renderEnvVars(['GOOGLE_PLACES_API_KEY'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -554,7 +549,7 @@ export default function AdminAppConfigurationClient({
|
||||
{' '}
|
||||
(default: {renderCommaSeparatedList(
|
||||
AI_AUTO_GENERATED_FIELDS_DEFAULT,
|
||||
)}):
|
||||
)})
|
||||
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -565,7 +560,7 @@ export default function AdminAppConfigurationClient({
|
||||
Store model in environment variable to use
|
||||
alternate OpenAI model
|
||||
{' '}
|
||||
{'(set to \'compatible\' to use gpt-4o):'}
|
||||
{'(set to \'compatible\' to use gpt-4o)'}
|
||||
{renderEnvVars(['OPENAI_MODEL'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -574,7 +569,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Store base URL in environment variable to use
|
||||
alternate OpenAI-compatible providers:
|
||||
alternate OpenAI-compatible providers
|
||||
{renderEnvVars(['OPENAI_BASE_URL'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -587,7 +582,7 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
Set environment variable to {'"1"'} to make site more responsive
|
||||
by enabling static optimization
|
||||
(i.e., rendering pages and images at build time):
|
||||
(i.e., rendering pages and images at build time)
|
||||
<div>
|
||||
{renderSubStatusWithEnvVar(
|
||||
arePhotosStaticallyOptimized ? 'checked' : 'optional',
|
||||
@ -614,7 +609,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -625,7 +620,7 @@ export default function AdminAppConfigurationClient({
|
||||
Set environment variable from {'"1-100"'}
|
||||
{' '}
|
||||
to control the quality of large photos
|
||||
({'"100"'} represents highest quality/largest size):
|
||||
({'"100"'} represents highest quality/largest size)
|
||||
{renderEnvVars(['NEXT_PUBLIC_IMAGE_QUALITY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -634,7 +629,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -650,7 +645,7 @@ export default function AdminAppConfigurationClient({
|
||||
Configure order and visibility of categories
|
||||
(seen in grid sidebar and CMD-K results)
|
||||
by storing comma-separated values
|
||||
(default: {renderCommaSeparatedList(DEFAULT_CATEGORY_KEYS)}):
|
||||
(default: {renderCommaSeparatedList(DEFAULT_CATEGORY_KEYS)})
|
||||
</div>
|
||||
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
|
||||
</ChecklistRow>
|
||||
@ -662,7 +657,7 @@ export default function AdminAppConfigurationClient({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
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'])}
|
||||
</div>
|
||||
</div>
|
||||
@ -675,7 +670,7 @@ export default function AdminAppConfigurationClient({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
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'])}
|
||||
</div>
|
||||
</div>
|
||||
@ -727,7 +722,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -739,7 +734,7 @@ export default function AdminAppConfigurationClient({
|
||||
Set environment variable to {'"1"'} to enable color-based sorting
|
||||
(forces nav sort control to {'"menu,"'} flags photos missing
|
||||
color data in admin dashboard)—color identification
|
||||
benefits greatly from AI being enabled:
|
||||
benefits greatly from AI being enabled
|
||||
{renderEnvVars([
|
||||
'NEXT_PUBLIC_COLOR_SORT',
|
||||
])}
|
||||
@ -753,7 +748,7 @@ export default function AdminAppConfigurationClient({
|
||||
Configure which colors start first
|
||||
(accepts a hue of 0 to 360, default: 80)
|
||||
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>
|
||||
<EnvVar
|
||||
variable="NEXT_PUBLIC_COLOR_SORT_STARTING_HUE"
|
||||
@ -777,19 +772,29 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
Set environment variable to {'"1"'} to take priority field
|
||||
into account when sorting photos (enabling may have
|
||||
performance consequences):
|
||||
performance consequences)
|
||||
{renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
case 'Display':
|
||||
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
|
||||
title="Show keyboard shortcut tooltips"
|
||||
status={showKeyboardShortcutTooltips}
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -797,7 +802,7 @@ export default function AdminAppConfigurationClient({
|
||||
status={showExifInfo}
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -806,7 +811,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to hide
|
||||
fullscreen photo zoom controls:
|
||||
fullscreen photo zoom controls
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -815,7 +820,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -823,7 +828,7 @@ export default function AdminAppConfigurationClient({
|
||||
status={showRepoLink}
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -835,7 +840,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to show grid layout
|
||||
on homepage:
|
||||
on homepage
|
||||
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -845,7 +850,7 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -855,7 +860,7 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
Set environment variable to {'"1"'} to ensure large thumbnails
|
||||
on photo grid views (if not configured, density is based on
|
||||
aspect ratio):
|
||||
aspect ratio)
|
||||
{renderEnvVars(['NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -870,7 +875,7 @@ export default function AdminAppConfigurationClient({
|
||||
{' '}
|
||||
to configure initial theme
|
||||
{' '}
|
||||
(defaults to {'\'system\''}):
|
||||
(defaults to {'\'system\''})
|
||||
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_THEME'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -880,7 +885,7 @@ export default function AdminAppConfigurationClient({
|
||||
>
|
||||
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">
|
||||
<EnvVar variable="NEXT_PUBLIC_MATTE_PHOTOS" />
|
||||
</div>
|
||||
@ -891,7 +896,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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">
|
||||
<EnvVar
|
||||
variable="NEXT_PUBLIC_MATTE_COLOR"
|
||||
@ -912,7 +917,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to disable
|
||||
collection/display of location-based data:
|
||||
collection/display of location-based data
|
||||
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -921,7 +926,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -943,9 +948,11 @@ export default function AdminAppConfigurationClient({
|
||||
status={areSiteFeedsEnabled}
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -954,7 +961,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -983,7 +990,7 @@ export default function AdminAppConfigurationClient({
|
||||
</MaskedScroll>)}
|
||||
</div>}
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -995,7 +1002,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to disable build identifier
|
||||
and admin configuration export:
|
||||
and admin configuration export
|
||||
{renderEnvVars(['DISABLE_DEBUG_OUTPUTS'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
@ -1007,7 +1014,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -1016,7 +1023,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to enable
|
||||
console output for all sql queries:
|
||||
console output for all sql queries
|
||||
{renderEnvVars(['ADMIN_SQL_DEBUG'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
@ -1025,7 +1032,7 @@ export default function AdminAppConfigurationClient({
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to enable
|
||||
storage debugging:
|
||||
storage debugging
|
||||
{renderEnvVars(['ADMIN_STORAGE_DEBUG'])}
|
||||
</ChecklistRow>
|
||||
</>;
|
||||
|
||||
@ -33,6 +33,7 @@ export default async function AlbumHeader({
|
||||
showAlbumMeta?: boolean
|
||||
}) {
|
||||
const appText = await getAppText();
|
||||
|
||||
return (
|
||||
<PhotoHeader
|
||||
album={album}
|
||||
|
||||
@ -3,6 +3,7 @@ import SwitcherItem from '@/components/switcher/SwitcherItem';
|
||||
import IconFull from '@/components/icons/IconFull';
|
||||
import IconGrid from '@/components/icons/IconGrid';
|
||||
import {
|
||||
PATH_ABOUT,
|
||||
PATH_FULL_INFERRED,
|
||||
PATH_GRID_INFERRED,
|
||||
} from '@/app/path';
|
||||
@ -12,6 +13,7 @@ import {
|
||||
GRID_HOMEPAGE_ENABLED,
|
||||
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||
NAV_SORT_CONTROL,
|
||||
SHOW_ABOUT_PAGE,
|
||||
} from './config';
|
||||
import AdminAppMenu from '@/admin/AdminAppMenu';
|
||||
import Spinner from '@/components/Spinner';
|
||||
@ -26,8 +28,9 @@ import { getSortStateFromPath } from '@/photo/sort/path';
|
||||
import { motion } from 'framer-motion';
|
||||
import SortMenu from '@/photo/sort/SortMenu';
|
||||
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_LEFT = 'ml-0.5 sm:ml-1';
|
||||
@ -36,10 +39,12 @@ export default function AppViewSwitcher({
|
||||
currentSelection,
|
||||
className,
|
||||
animate = true,
|
||||
hideSortControl,
|
||||
}: {
|
||||
currentSelection?: SwitcherSelection
|
||||
className?: string
|
||||
animate?: boolean
|
||||
hideSortControl?: boolean
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@ -69,7 +74,8 @@ export default function AppViewSwitcher({
|
||||
|
||||
const showSortControl =
|
||||
NAV_SORT_CONTROL !== 'none' &&
|
||||
doesPathOfferSort;
|
||||
doesPathOfferSort &&
|
||||
!hideSortControl;
|
||||
|
||||
const hasLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@ -82,6 +88,7 @@ export default function AppViewSwitcher({
|
||||
|
||||
const refHrefFull = useRef<HTMLAnchorElement>(null);
|
||||
const refHrefGrid = useRef<HTMLAnchorElement>(null);
|
||||
const refHrefAbout = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
|
||||
|
||||
@ -94,19 +101,19 @@ export default function AppViewSwitcher({
|
||||
case KEY_COMMANDS.grid:
|
||||
if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); }
|
||||
break;
|
||||
case KEY_COMMANDS.admin:
|
||||
if (isUserSignedIn) { setIsAdminMenuOpen(true); }
|
||||
case KEY_COMMANDS.about:
|
||||
if (pathname !== PATH_ABOUT) { refHrefAbout.current?.click(); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [pathname, isUserSignedIn]);
|
||||
}, [pathname]);
|
||||
useKeydownHandler({ onKeyDown });
|
||||
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||
|
||||
const renderItemFull =
|
||||
<SwitcherItem
|
||||
icon={<IconFull includeTitle={false} />}
|
||||
icon={<IconFull />}
|
||||
href={pathFull}
|
||||
hrefRef={refHrefFull}
|
||||
active={currentSelection === 'full'}
|
||||
@ -119,7 +126,7 @@ export default function AppViewSwitcher({
|
||||
|
||||
const renderItemGrid =
|
||||
<SwitcherItem
|
||||
icon={<IconGrid includeTitle={false} />}
|
||||
icon={<IconGrid />}
|
||||
href={pathGrid}
|
||||
hrefRef={refHrefGrid}
|
||||
active={currentSelection === 'grid'}
|
||||
@ -141,6 +148,18 @@ export default function AppViewSwitcher({
|
||||
>
|
||||
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
|
||||
{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 */}
|
||||
{(isUserSignedInEager && !isUserSignedIn) &&
|
||||
<SwitcherItem
|
||||
@ -150,7 +169,6 @@ export default function AppViewSwitcher({
|
||||
tooltip={{
|
||||
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||
content: appText.nav.admin,
|
||||
keyCommand: KEY_COMMANDS.admin,
|
||||
},
|
||||
}}
|
||||
/>}
|
||||
@ -166,7 +184,6 @@ export default function AppViewSwitcher({
|
||||
tooltip={{
|
||||
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||
content: appText.nav.admin,
|
||||
keyCommand: KEY_COMMANDS.admin,
|
||||
},
|
||||
}}
|
||||
noPadding
|
||||
@ -224,7 +241,7 @@ export default function AppViewSwitcher({
|
||||
</motion.div>
|
||||
<Switcher type="borderless">
|
||||
<SwitcherItem
|
||||
icon={<IconSearch includeTitle={false} />}
|
||||
icon={<IconSearch />}
|
||||
onClick={() => setIsCommandKOpen?.(true)}
|
||||
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
|
||||
content: appText.nav.search,
|
||||
|
||||
@ -53,7 +53,7 @@ export default function Footer() {
|
||||
? <>
|
||||
<Link
|
||||
href={PATH_ADMIN_PHOTOS}
|
||||
className="truncate max-w-full"
|
||||
className="truncate max-w-full max-sm:hidden"
|
||||
>
|
||||
{userEmail || userEmailEager}
|
||||
</Link>
|
||||
|
||||
@ -7,6 +7,6 @@ export default async function Nav() {
|
||||
return <NavClient
|
||||
navTitle={NAV_TITLE}
|
||||
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 {
|
||||
PATH_ROOT,
|
||||
isPathAbout,
|
||||
isPathAdmin,
|
||||
isPathFull,
|
||||
isPathGrid,
|
||||
@ -29,11 +30,11 @@ const NAV_HEIGHT_CLASS = NAV_CAPTION
|
||||
export default function NavClient({
|
||||
navTitle,
|
||||
navCaption,
|
||||
animate,
|
||||
isInEmptyState,
|
||||
}: {
|
||||
navTitle: string
|
||||
navCaption?: string
|
||||
animate: boolean
|
||||
isInEmptyState: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
|
||||
@ -65,6 +66,8 @@ export default function NavClient({
|
||||
return 'grid';
|
||||
} else if (isPathFull(pathname)) {
|
||||
return 'full';
|
||||
} else if (isPathAbout(pathname)) {
|
||||
return 'about';
|
||||
} else if (isPathProtected(pathname)) {
|
||||
return 'admin';
|
||||
}
|
||||
@ -77,14 +80,14 @@ export default function NavClient({
|
||||
contentMain={
|
||||
<AnimateItems
|
||||
animateOnFirstLoadOnly
|
||||
type={animate && !isPathAdmin(pathname) ? 'bottom' : 'none'}
|
||||
type={!isInEmptyState && !isPathAdmin(pathname) ? 'bottom' : 'none'}
|
||||
distanceOffset={10}
|
||||
items={showNav
|
||||
? [<nav
|
||||
key="nav"
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full flex items-center bg-main',
|
||||
'w-full flex items-center gap-2 bg-main',
|
||||
NAV_HEIGHT_CLASS,
|
||||
// Enlarge nav to ensure it fully masks underlying content
|
||||
'md:w-[calc(100%+8px)] md:translate-x-[-4px] md:px-[4px]',
|
||||
@ -94,6 +97,7 @@ export default function NavClient({
|
||||
currentSelection={switcherSelectionForPath()}
|
||||
className="translate-x-[-1px]"
|
||||
animate={hasLoadedWithAnimations && isNavVisible}
|
||||
hideSortControl={isInEmptyState}
|
||||
/>
|
||||
<div className={clsx(
|
||||
'grow text-right min-w-0',
|
||||
|
||||
@ -66,13 +66,13 @@ export default function TemplateImageResponse({
|
||||
color: '#333',
|
||||
borderRight: '2px solid #333',
|
||||
}}>
|
||||
<IconFull includeTitle={false} width={80} />
|
||||
<IconFull width={80} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
padding: '3px 10px',
|
||||
}}>
|
||||
<IconGrid includeTitle={false} width={80} />
|
||||
<IconGrid width={80} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -146,9 +146,10 @@ export const NAV_TITLE =
|
||||
SITE_DOMAIN_SHORT ||
|
||||
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 ||
|
||||
// Legacy environment variable
|
||||
process.env.NEXT_PUBLIC_SITE_ABOUT;
|
||||
|
||||
// STORAGE
|
||||
@ -323,6 +324,8 @@ export const NAV_SORT_CONTROL = COLOR_SORT_ENABLED
|
||||
|
||||
// DISPLAY
|
||||
|
||||
export const SHOW_ABOUT_PAGE =
|
||||
process.env.NEXT_PUBLIC_HIDE_ABOUT_PAGE !== '1';
|
||||
export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
||||
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
||||
export const SHOW_EXIF_DATA =
|
||||
@ -442,8 +445,8 @@ export const APP_CONFIGURATION = {
|
||||
hasNavTitle: Boolean(CUSTOM_NAV_TITLE),
|
||||
navCaption: NAV_CAPTION,
|
||||
hasNavCaption: Boolean(NAV_CAPTION),
|
||||
pageAbout: PAGE_ABOUT,
|
||||
hasPageAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT),
|
||||
sidebarText: SIDEBAR_TEXT,
|
||||
hasSidebarText: Boolean(SIDEBAR_TEXT),
|
||||
// Performance
|
||||
isStaticallyOptimized: HAS_STATIC_OPTIMIZATION,
|
||||
arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS,
|
||||
@ -489,6 +492,7 @@ export const APP_CONFIGURATION = {
|
||||
colorSortChromaCutoff: COLOR_SORT_CHROMA_CUTOFF,
|
||||
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
|
||||
// Display
|
||||
showAboutPage: SHOW_ABOUT_PAGE,
|
||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||
showExifInfo: SHOW_EXIF_DATA,
|
||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||
@ -552,7 +556,10 @@ const ALL_DEPRECATED_ENV_VARS = [{
|
||||
replacement: 'NEXT_PUBLIC_META_TITLE',
|
||||
}, {
|
||||
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',
|
||||
replacement: 'NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS',
|
||||
|
||||
@ -11,6 +11,7 @@ import { AlbumOrAlbumSlug } from '@/album';
|
||||
export const PATH_ROOT = '/';
|
||||
export const PATH_GRID = '/grid';
|
||||
export const PATH_FULL = '/full';
|
||||
export const PATH_ABOUT = '/about';
|
||||
export const PATH_ADMIN = '/admin';
|
||||
export const PATH_API = '/api';
|
||||
export const PATH_SIGN_IN = '/sign-in';
|
||||
@ -24,6 +25,10 @@ export const PATH_FULL_INFERRED = GRID_HOMEPAGE_ENABLED
|
||||
? PATH_FULL
|
||||
: PATH_ROOT;
|
||||
|
||||
// Modifiers
|
||||
const EDIT = 'edit';
|
||||
const IMAGE = 'image';
|
||||
|
||||
// Sort
|
||||
export const PARAM_SORT_TYPE_TAKEN_AT = 'taken-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_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
||||
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_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_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`;
|
||||
|
||||
// Modifiers
|
||||
const EDIT = 'edit';
|
||||
const IMAGE = 'image';
|
||||
|
||||
// Parameters
|
||||
export const PARAM_UPLOAD_TITLE = 'title';
|
||||
export const PARAM_SELECT = 'select';
|
||||
@ -107,6 +109,7 @@ export const PATHS_ADMIN = [
|
||||
PATH_ADMIN_RECIPES,
|
||||
PATH_ADMIN_INSIGHTS,
|
||||
PATH_ADMIN_CONFIGURATION,
|
||||
PATH_ADMIN_ABOUT_EDIT,
|
||||
PATH_ADMIN_BASELINE,
|
||||
PATH_ADMIN_COMPONENTS,
|
||||
];
|
||||
@ -115,6 +118,7 @@ export const PATHS_TO_CACHE = [
|
||||
PATH_ROOT,
|
||||
PATH_GRID,
|
||||
PATH_FULL,
|
||||
PATH_ABOUT,
|
||||
PATH_OG,
|
||||
PATH_PHOTO_DYNAMIC,
|
||||
PATH_CAMERA_DYNAMIC,
|
||||
@ -430,10 +434,14 @@ export const isPathGrid = (pathname?: string) =>
|
||||
export const isPathFull = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_FULL);
|
||||
|
||||
export const isPathAbout = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_ABOUT);
|
||||
|
||||
export const isPathTopLevel = (pathname?: string) =>
|
||||
isPathRoot(pathname)||
|
||||
isPathRoot(pathname) ||
|
||||
isPathGrid(pathname) ||
|
||||
isPathFull(pathname);
|
||||
isPathFull(pathname) ||
|
||||
isPathAbout(pathname);
|
||||
|
||||
export const isPathSignIn = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_SIGN_IN);
|
||||
@ -460,6 +468,7 @@ export const isPathAdminInfo = (pathname?: string) =>
|
||||
export const isPathProtected = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_ADMIN) ||
|
||||
checkPathPrefix(pathname, pathForTag(TAG_PRIVATE)) ||
|
||||
checkPathPrefix(pathname, PATH_ADMIN_ABOUT_EDIT) ||
|
||||
checkPathPrefix(pathname, PATH_OG);
|
||||
|
||||
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 { revalidatePath, revalidateTag } from 'next/cache';
|
||||
|
||||
// Page keys
|
||||
export const KEY_ABOUT = 'about';
|
||||
// Table key
|
||||
export const KEY_PHOTOS = 'photos';
|
||||
export const KEY_PHOTO = 'photo';
|
||||
// Field keys
|
||||
export const KEY_YEARS = 'years';
|
||||
export const KEY_CAMERAS = 'cameras';
|
||||
export const KEY_LENSES = 'lenses';
|
||||
export const KEY_ALBUMS = 'albums';
|
||||
export const KEY_TAGS = 'tags';
|
||||
export const KEY_FILMS = 'films';
|
||||
export const KEY_RECIPES = 'recipes';
|
||||
export const KEY_FILMS = 'films';
|
||||
export const KEY_FOCAL_LENGTHS = 'focal-lengths';
|
||||
export const KEY_YEARS = 'years';
|
||||
// Type keys
|
||||
export const KEY_COUNT = 'count';
|
||||
export const KEY_DATE_RANGE = 'date-range';
|
||||
|
||||
export const revalidateAboutKey = () =>
|
||||
revalidateTag(KEY_ABOUT, 'max');
|
||||
|
||||
export const revalidatePhotosKey = () =>
|
||||
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 = () =>
|
||||
revalidateTag(KEY_ALBUMS, 'max');
|
||||
|
||||
@ -29,31 +43,23 @@ export const revalidateTagsKey = () =>
|
||||
export const revalidateRecipesKey = () =>
|
||||
revalidateTag(KEY_RECIPES, 'max');
|
||||
|
||||
export const revalidateCamerasKey = () =>
|
||||
revalidateTag(KEY_CAMERAS, 'max');
|
||||
|
||||
export const revalidateLensesKey = () =>
|
||||
revalidateTag(KEY_LENSES, 'max');
|
||||
|
||||
export const revalidateFilmsKey = () =>
|
||||
revalidateTag(KEY_FILMS, 'max');
|
||||
|
||||
export const revalidateFocalLengthsKey = () =>
|
||||
revalidateTag(KEY_FOCAL_LENGTHS, 'max');
|
||||
|
||||
export const revalidateYearsKey = () =>
|
||||
revalidateTag(KEY_YEARS, 'max');
|
||||
|
||||
export const revalidateAllKeys = () => {
|
||||
revalidateAboutKey();
|
||||
revalidatePhotosKey();
|
||||
revalidateAlbumsKey();
|
||||
revalidateTagsKey();
|
||||
revalidateYearsKey();
|
||||
revalidateCamerasKey();
|
||||
revalidateLensesKey();
|
||||
revalidateFilmsKey();
|
||||
revalidateAlbumsKey();
|
||||
revalidateTagsKey();
|
||||
revalidateRecipesKey();
|
||||
revalidateFilmsKey();
|
||||
revalidateFocalLengthsKey();
|
||||
revalidateYearsKey();
|
||||
};
|
||||
|
||||
export const revalidateAdminPaths = () => {
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
} from '@/app/config';
|
||||
import { createLensKey } from '@/lens';
|
||||
import { sortTagsByCount } from '@/tag';
|
||||
import { sortCategoriesByCount } from '@/category';
|
||||
import { PhotoSetCategories, sortCategoriesByCount } from '@/category';
|
||||
import { sortFocalLengths } from '@/focal';
|
||||
import {
|
||||
getPhotosMetaCached,
|
||||
@ -160,3 +160,31 @@ export const getCountsForCategories = async () => {
|
||||
}, {} 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,
|
||||
} from 'react';
|
||||
import {
|
||||
PATH_ABOUT,
|
||||
PATH_ADMIN_BASELINE,
|
||||
PATH_ADMIN_COMPONENTS,
|
||||
PATH_ADMIN_CONFIGURATION,
|
||||
@ -63,6 +64,7 @@ import {
|
||||
COLOR_SORT_ENABLED,
|
||||
GRID_HOMEPAGE_ENABLED,
|
||||
HIDE_TAGS_WITH_ONE_PHOTO,
|
||||
SHOW_ABOUT_PAGE,
|
||||
} from '@/app/config';
|
||||
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
|
||||
@ -611,6 +613,13 @@ export default function CommandKClient({
|
||||
? [pageGrid, pageFull]
|
||||
: [pageFull, pageGrid];
|
||||
|
||||
if (SHOW_ABOUT_PAGE) {
|
||||
pageItems.push({
|
||||
label: appText.nav.about,
|
||||
path: PATH_ABOUT,
|
||||
});
|
||||
}
|
||||
|
||||
const sectionPages: CommandKSection = {
|
||||
heading: appText.cmdk.pages,
|
||||
accessory: <CgFileDocument size={14} className="translate-x-[-0.5px]" />,
|
||||
|
||||
@ -32,7 +32,7 @@ export default function Container({
|
||||
case 'gray-border': return [
|
||||
'text-medium',
|
||||
'bg-extra-dim',
|
||||
'border-medium',
|
||||
'border border-medium',
|
||||
];
|
||||
case 'blue': return [
|
||||
'text-blue-800 dark:text-blue-400',
|
||||
|
||||
@ -44,6 +44,8 @@ export default function HeaderList({
|
||||
'dark:text-gray-100',
|
||||
'flex items-center mb-1 gap-1',
|
||||
'uppercase select-none',
|
||||
'text-sm tracking-wide',
|
||||
'translate-x-px',
|
||||
)}
|
||||
>
|
||||
{icon &&
|
||||
@ -67,7 +69,7 @@ export default function HeaderList({
|
||||
className={clsx(
|
||||
'mt-0.5',
|
||||
'text-xs font-medium tracking-wider',
|
||||
'border-medium rounded-md',
|
||||
'border border-medium rounded-md',
|
||||
'px-[5px] h-5!',
|
||||
'hover:bg-dim hover:text-main active:bg-main',
|
||||
'group',
|
||||
|
||||
@ -13,18 +13,21 @@ import { useAppText } from '@/i18n/state/client';
|
||||
export default function ImageInput({
|
||||
ref: inputRefExternal,
|
||||
id = 'file',
|
||||
className,
|
||||
onStart,
|
||||
onBlobReady,
|
||||
multiple = true,
|
||||
shouldResize,
|
||||
maxSize = MAX_IMAGE_SIZE,
|
||||
quality = 0.9,
|
||||
hidden,
|
||||
showButton,
|
||||
disabled: disabledProp,
|
||||
debug: _debug,
|
||||
}: {
|
||||
ref?: RefObject<HTMLInputElement | null>
|
||||
id?: string
|
||||
className?: string
|
||||
onStart?: () => void
|
||||
onBlobReady?: (args: {
|
||||
blob: Blob,
|
||||
@ -36,6 +39,7 @@ export default function ImageInput({
|
||||
shouldResize?: boolean
|
||||
maxSize?: number
|
||||
quality?: number
|
||||
hidden?: boolean
|
||||
showButton?: boolean
|
||||
disabled?: boolean
|
||||
debug?: boolean
|
||||
@ -59,7 +63,10 @@ export default function ImageInput({
|
||||
const disabled = disabledProp || isUploading;
|
||||
|
||||
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">
|
||||
<label
|
||||
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({
|
||||
width = INTRINSIC_WIDTH,
|
||||
includeTitle = true,
|
||||
className,
|
||||
}: {
|
||||
width?: number
|
||||
includeTitle?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
@ -22,10 +20,9 @@ export default function IconFull({
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
{includeTitle && <title>Full Frame</title>}
|
||||
<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="M5.5 4.875H22.5" strokeWidth="1.25"/>
|
||||
<path d="M22.5 19.125L5.5 19.125" strokeWidth="1.25"/>
|
||||
<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 5.38H22" strokeWidth="1.25"/>
|
||||
<path d="M22 18.62L6 18.62" strokeWidth="1.25"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,11 +5,9 @@ const INTRINSIC_HEIGHT = 24;
|
||||
|
||||
export default function IconGrid({
|
||||
width = INTRINSIC_WIDTH,
|
||||
includeTitle = true,
|
||||
className,
|
||||
}: {
|
||||
width?: number
|
||||
includeTitle?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
@ -22,8 +20,7 @@ export default function IconGrid({
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
{includeTitle && <title>Grid</title>}
|
||||
<rect x="5.625" y="6.625" width="16.75" height="10.75" rx="1" strokeWidth="1.25"/>
|
||||
<rect x="5.625" y="6.625" width="16.75" height="10.75" rx="2.375" 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="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({
|
||||
width = INTRINSIC_WIDTH,
|
||||
includeTitle = true,
|
||||
}: {
|
||||
width?: number;
|
||||
includeTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
@ -17,7 +15,6 @@ export default function IconSearch({
|
||||
stroke="currentColor"
|
||||
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" />
|
||||
<path d="M17 15L21 19" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
|
||||
@ -35,7 +35,7 @@ export default function OGTile({
|
||||
className={clsx(
|
||||
'group',
|
||||
'block w-full rounded-md overflow-hidden',
|
||||
'border-medium shadow-xs',
|
||||
'border border-medium shadow-xs',
|
||||
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -152,9 +152,9 @@ export default function SharedHoverProvider({
|
||||
{/* Border */}
|
||||
<div className={clsx(
|
||||
'absolute inset-0',
|
||||
'rounded-[0.25rem]',
|
||||
'border rounded-[0.25rem]',
|
||||
hoverProps.color === 'frosted'
|
||||
? 'border border-gray-400/25'
|
||||
? 'border-gray-400/25'
|
||||
: 'border-medium',
|
||||
)} />
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,7 @@ export default function Switcher({
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex divide-x overflow-hidden',
|
||||
'rounded-[7px]',
|
||||
'rounded-lg',
|
||||
'divide-medium',
|
||||
type === 'regular' &&
|
||||
'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 Tooltip from '../Tooltip';
|
||||
|
||||
const WIDTH_CLASS = 'w-[42px]';
|
||||
const WIDTH_CLASS_NARROW = 'w-[36px]';
|
||||
export const SWITCHER_ITEM_WIDTH = 46;
|
||||
|
||||
export const WIDTH_CLASS = 'w-[46px]';
|
||||
export const WIDTH_CLASS_NARROW = 'w-[36px]';
|
||||
export const HEIGHT_CLASS = 'h-[32px]';
|
||||
|
||||
export default function SwitcherItem({
|
||||
icon,
|
||||
@ -38,7 +41,7 @@ export default function SwitcherItem({
|
||||
const widthClass = width === 'narrow' ? WIDTH_CLASS_NARROW : WIDTH_CLASS;
|
||||
const className = clsx(
|
||||
'flex items-center justify-center',
|
||||
`${widthClass} h-[30px]`,
|
||||
`${widthClass} ${HEIGHT_CLASS}`,
|
||||
isInteractive && 'cursor-pointer',
|
||||
isInteractive && 'hover:bg-gray-100/60 active:bg-gray-100',
|
||||
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 { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config';
|
||||
import { createAlbumPhotoTable, createAlbumsTable } from '@/album/query';
|
||||
import { createAboutTable } from '@/about/query';
|
||||
|
||||
// Safe wrapper intended for most queries with JIT migration/table creation
|
||||
// Catches up to 3 migrations in older installations
|
||||
@ -54,6 +55,7 @@ export const safelyQuery = async <T>(
|
||||
await createPhotosTable();
|
||||
await createAlbumsTable();
|
||||
await createAlbumPhotoTable();
|
||||
await createAboutTable();
|
||||
result = await callback();
|
||||
} else if (/relation "albums" does not exist/i.test(e.message)) {
|
||||
// Create albums tables if they don't exist
|
||||
@ -61,6 +63,11 @@ export const safelyQuery = async <T>(
|
||||
await createAlbumsTable();
|
||||
await createAlbumPhotoTable();
|
||||
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)) {
|
||||
console.log(
|
||||
'SQL query error: endpoint is in transition (setting timeout)',
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'হোম',
|
||||
full: 'সম্পূর্ণ',
|
||||
grid: 'গ্রিড',
|
||||
about: 'সম্পর্কে',
|
||||
admin: 'অ্যাডমিন',
|
||||
search: 'সার্চ',
|
||||
prev: 'পূর্ববর্তী',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: 'পরবর্তী',
|
||||
nextShort: 'পরবর্তী',
|
||||
},
|
||||
about: {
|
||||
titleDefault: 'এই সাইট সম্পর্কে',
|
||||
updated: '{{distance}} আগে আপডেট হয়েছে',
|
||||
photoCount: 'ছবির সংখ্যা',
|
||||
firstPhoto: 'প্রথম ছবি',
|
||||
topCamera: 'শীর্ষ ক্যামেরা',
|
||||
topLens: 'শীর্ষ লেন্স',
|
||||
topRecipe: 'শীর্ষ রেসিপি',
|
||||
topFilm: 'শীর্ষ ফিল্ম',
|
||||
recentAlbum: 'সাম্প্রতিক অ্যালবাম',
|
||||
popularTag: 'জনপ্রিয় ট্যাগ',
|
||||
},
|
||||
footer: {
|
||||
madeWith: 'তৈরি হয়েছে',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'Home',
|
||||
full: 'Full',
|
||||
grid: 'Grid',
|
||||
about: 'About',
|
||||
admin: 'Admin',
|
||||
search: 'Search',
|
||||
prev: 'Previous',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: '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: {
|
||||
madeWith: 'Made with',
|
||||
},
|
||||
|
||||
@ -47,6 +47,7 @@ export const TEXT = {
|
||||
home: 'Home',
|
||||
full: 'Full',
|
||||
grid: 'Grid',
|
||||
about: 'About',
|
||||
admin: 'Admin',
|
||||
search: 'Search',
|
||||
prev: 'Previous',
|
||||
@ -54,6 +55,18 @@ export const TEXT = {
|
||||
next: '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: {
|
||||
madeWith: 'Made with',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'होम',
|
||||
full: 'पूर्ण',
|
||||
grid: 'ग्रिड',
|
||||
about: 'के बारे में',
|
||||
admin: 'एडमिन',
|
||||
search: 'खोज',
|
||||
prev: 'पिछला',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: 'अगला',
|
||||
nextShort: 'अगला',
|
||||
},
|
||||
about: {
|
||||
titleDefault: 'इस साइट के बारे में',
|
||||
updated: '{{distance}} पहले अपडेट किया गया',
|
||||
photoCount: 'फोटो की संख्या',
|
||||
firstPhoto: 'पहली फोटो',
|
||||
topCamera: 'शीर्ष कैमरा',
|
||||
topLens: 'शीर्ष लेंस',
|
||||
topRecipe: 'शीर्ष रेसिपी',
|
||||
topFilm: 'शीर्ष फिल्म',
|
||||
recentAlbum: 'हाल का एल्बम',
|
||||
popularTag: 'लोकप्रिय टैग',
|
||||
},
|
||||
footer: {
|
||||
madeWith: 'निर्मित',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'Beranda',
|
||||
full: 'Lengkap',
|
||||
grid: 'Grid',
|
||||
about: 'Tentang',
|
||||
admin: 'Admin',
|
||||
search: 'Cari',
|
||||
prev: 'Sebelumnya',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: 'Berikutnya',
|
||||
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: {
|
||||
madeWith: 'Dibuat dengan',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'Início',
|
||||
full: 'Completo',
|
||||
grid: 'Grade',
|
||||
about: 'Sobre',
|
||||
admin: 'Menu de administrador',
|
||||
search: 'Pesquisar',
|
||||
prev: 'Anterior',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: 'Próximo',
|
||||
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: {
|
||||
madeWith: 'Feito com',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'Início',
|
||||
full: 'Completo',
|
||||
grid: 'Grade',
|
||||
about: 'Sobre',
|
||||
admin: 'Menu de administração',
|
||||
search: 'Pesquisar',
|
||||
prev: 'Anterior',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: 'Próximo',
|
||||
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: {
|
||||
madeWith: 'Feito com',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'Anasayfa',
|
||||
full: 'Tam',
|
||||
grid: 'Izgara',
|
||||
about: 'Hakkında',
|
||||
admin: 'Yönetici',
|
||||
search: 'Ara',
|
||||
prev: 'Önceki',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: '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: {
|
||||
madeWith: 'Hazırlayan:',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: 'Trang chủ',
|
||||
full: 'Toàn bộ',
|
||||
grid: 'Lưới',
|
||||
about: 'Giới thiệu',
|
||||
admin: 'Quản trị',
|
||||
search: 'Tìm kiếm',
|
||||
prev: 'Trước',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: '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: {
|
||||
madeWith: 'Được tạo bằng',
|
||||
},
|
||||
|
||||
@ -48,6 +48,7 @@ export const TEXT: I18N = {
|
||||
home: '首页',
|
||||
full: '完整',
|
||||
grid: '网格',
|
||||
about: '关于',
|
||||
admin: '管理',
|
||||
search: '搜索',
|
||||
prev: '上一页',
|
||||
@ -55,6 +56,18 @@ export const TEXT: I18N = {
|
||||
next: '下一页',
|
||||
nextShort: '下一页',
|
||||
},
|
||||
about: {
|
||||
titleDefault: '关于本网站',
|
||||
updated: '{{distance}} 前更新',
|
||||
photoCount: '照片数量',
|
||||
firstPhoto: '第一张照片',
|
||||
topCamera: '常用相机',
|
||||
topLens: '常用镜头',
|
||||
topRecipe: '常用配方',
|
||||
topFilm: '常用胶片',
|
||||
recentAlbum: '最近相册',
|
||||
popularTag: '热门标签',
|
||||
},
|
||||
footer: {
|
||||
madeWith: '基于',
|
||||
},
|
||||
|
||||
@ -28,6 +28,11 @@ export const generateAppTextState = (i18n: I18N) => {
|
||||
recentSubhead: (distance: string) =>
|
||||
i18n.category.recentSubhead.replace('{{distance}}', distance),
|
||||
},
|
||||
about: {
|
||||
...i18n.about,
|
||||
updated: (distance: string) =>
|
||||
i18n.about.updated.replace('{{distance}}', distance),
|
||||
},
|
||||
admin: {
|
||||
...i18n.admin,
|
||||
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,
|
||||
safelyParseFormattedHtml,
|
||||
} from '@/utility/html';
|
||||
import { PAGE_ABOUT } from '@/app/config';
|
||||
import { SIDEBAR_TEXT } from '@/app/config';
|
||||
|
||||
export default function PhotoGridPage(
|
||||
props: ComponentProps<typeof PhotoGridPageClient>,
|
||||
) {
|
||||
const aboutTextSafelyParsedHtml = PAGE_ABOUT
|
||||
? safelyParseFormattedHtml(PAGE_ABOUT)
|
||||
const aboutTextSafelyParsedHtml = SIDEBAR_TEXT
|
||||
? safelyParseFormattedHtml(SIDEBAR_TEXT)
|
||||
: undefined;
|
||||
const aboutTextHasBrParagraphBreaks = PAGE_ABOUT
|
||||
? htmlHasBrParagraphBreaks(PAGE_ABOUT)
|
||||
const aboutTextHasBrParagraphBreaks = SIDEBAR_TEXT
|
||||
? htmlHasBrParagraphBreaks(SIDEBAR_TEXT)
|
||||
: false;
|
||||
|
||||
return <PhotoGridPageClient {...{
|
||||
|
||||
@ -763,6 +763,11 @@ export const batchPhotoAction = async ({
|
||||
revalidateAllKeysAndPaths();
|
||||
});
|
||||
|
||||
export const getPhotoAction = async (photoId: string) =>
|
||||
runAuthenticatedAdminServerAction(async () =>
|
||||
getPhoto(photoId, true),
|
||||
);
|
||||
|
||||
// Public/Private actions
|
||||
|
||||
export const getPhotosAction = async (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export const KEY_COMMANDS = {
|
||||
full: 'F',
|
||||
grid: 'G',
|
||||
admin: 'A',
|
||||
about: 'A',
|
||||
prev: ['J', 'ARROWLEFT'],
|
||||
next: ['L', 'ARROWRIGHT'],
|
||||
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',
|
||||
'w-full overflow-hidden',
|
||||
'flex items-center justify-stretch',
|
||||
'border-medium',
|
||||
'border border-medium',
|
||||
)}>
|
||||
<MaskedScroll
|
||||
className="flex grow"
|
||||
|
||||
@ -157,7 +157,7 @@ html {
|
||||
}
|
||||
@utility border-medium {
|
||||
@apply
|
||||
border border-gray-400/25 dark:border-gray-800
|
||||
border-gray-400/25 dark:border-gray-800
|
||||
}
|
||||
@utility outline-medium {
|
||||
@apply
|
||||
|
||||
Loading…
Reference in New Issue
Block a user