Simplify About page to title, subhead, and description only.

Remove avatar/hero photos and gallery stats from the public About view and admin editor, and add env-backed defaults for ABOUT_TITLE and ABOUT_SUBHEAD.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Strtus 2026-05-20 17:16:31 +08:00
parent 2315647743
commit 8e6df1e70c
6 changed files with 53 additions and 361 deletions

View File

@ -1,53 +1,9 @@
import AdminAboutEditPage from '@/about/AdminAboutEditPage'; import AdminAboutEditPage from '@/about/AdminAboutEditPage';
import { getAboutData } from '@/about/data'; import { getAboutData } from '@/about/data';
import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
import { feedQueryOptions } from '@/feed';
import {
getPhotosCached,
getPhotosMetaCached,
} from '@/photo/cache';
import { TAG_FAVS } from '@/tag';
const PHOTO_CHOOSER_QUERY_OPTIONS = feedQueryOptions({
isGrid: true,
excludeFromFeeds: false,
});
export default async function AboutEditPage() { export default async function AboutEditPage() {
const [ const { about } = await getAboutData()
{ .catch(() => ({ about: undefined }));
about,
photoAvatar,
photoHero,
},
photos,
photosCount,
photosFavs,
] = await Promise.all([
getAboutData()
.catch(() => ({
about: undefined,
photoAvatar: undefined,
photoHero: undefined,
})),
getPhotosCached(PHOTO_CHOOSER_QUERY_OPTIONS)
.catch(() => []),
getPhotosMetaCached(PHOTO_CHOOSER_QUERY_OPTIONS)
.then(({ count }) => count)
.catch(() => 0),
getPhotosCached({ tag: TAG_FAVS })
.catch(() => []),
]);
return ( return <AdminAboutEditPage about={about} />;
<AdminAboutEditPage {...{
about,
photoAvatar,
photoHero,
photos,
photosCount,
photosFavs,
shouldResizeImages: !PRESERVE_ORIGINAL_UPLOADS,
}} />
);
} }

View File

@ -1,18 +1,13 @@
import AboutPageClient from '@/about/AboutPageClient'; import AboutPageClient from '@/about/AboutPageClient';
import { getAboutDataCached } from '@/about/data'; import { getAboutDataCached } from '@/about/data';
import { ABOUT_DESCRIPTION_DEFAULT, SHOW_ABOUT_PAGE } from '@/app/config';
import { PATH_ROOT } from '@/app/path';
import { getDataForCategoriesCached } from '@/category/cache';
import { import {
getLastModifiedForCategories, ABOUT_DESCRIPTION_DEFAULT,
NULL_CATEGORY_DATA, ABOUT_SUBHEAD,
} from '@/category/data'; ABOUT_TITLE,
import { getPhotosMetaCached } from '@/photo/cache'; SHOW_ABOUT_PAGE,
import PhotosEmptyState from '@/photo/PhotosEmptyState'; } from '@/app/config';
import { getAllPhotoIdsWithUpdatedAt } from '@/photo/query'; import { PATH_ROOT } from '@/app/path';
import { TAG_FAVS } from '@/tag';
import { safelyParseFormattedHtml } from '@/utility/html'; import { safelyParseFormattedHtml } from '@/utility/html';
import { max } from 'date-fns';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@ -20,75 +15,27 @@ export const dynamic = 'force-static';
export default async function AboutPage() { export default async function AboutPage() {
if (!SHOW_ABOUT_PAGE) { redirect(PATH_ROOT); } if (!SHOW_ABOUT_PAGE) { redirect(PATH_ROOT); }
const [ const { about } = await getAboutDataCached()
{ .catch(() => ({ about: undefined }));
about,
photoAvatar,
photoHero,
},
photosMeta,
photos,
categories,
] = await Promise.all([
getAboutDataCached()
.catch(() => ({
about: undefined,
photoAvatar: undefined,
photoHero: undefined,
})),
getPhotosMetaCached().catch(() => {}),
getAllPhotoIdsWithUpdatedAt().catch(() => []),
getDataForCategoriesCached().catch(() => (NULL_CATEGORY_DATA)),
]);
const title = about?.title || ABOUT_TITLE;
const subhead = about?.subhead || ABOUT_SUBHEAD;
const description = about?.description || ABOUT_DESCRIPTION_DEFAULT; const description = about?.description || ABOUT_DESCRIPTION_DEFAULT;
const descriptionHtml = description const descriptionHtml = description
? <div ? <div
className="text-medium [&>*>a]:underline" className="text-medium leading-relaxed [&>*>a]:underline space-y-4"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: safelyParseFormattedHtml(description), __html: safelyParseFormattedHtml(description),
}} }}
/> />
: undefined; : undefined;
const {
cameras,
lenses,
albums,
tags,
recipes,
films,
} = categories;
const place = albums
.slice()
.sort((a, b) => b.count - a.count)[0]?.album.location;
const lastModifiedSite = max([
getLastModifiedForCategories(categories, photos),
about?.updatedAt,
].filter(date => date instanceof Date));
return ( return (
(photosMeta?.count ?? 0) > 0 <AboutPageClient
? <AboutPageClient title={title}
title={about?.title} subhead={subhead}
subhead={about?.subhead} descriptionHtml={descriptionHtml}
descriptionHtml={descriptionHtml} />
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}
tag={tags.filter(({ tag }) => tag !== TAG_FAVS)[0]?.tag}
place={place}
album={albums[0]?.album}
lastUpdated={lastModifiedSite}
/>
: <PhotosEmptyState />
); );
} }

View File

@ -1,194 +1,45 @@
'use client'; 'use client';
import PhotoAlbum from '@/album/PhotoAlbum';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import PhotoCamera from '@/camera/PhotoCamera';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import PhotoFilm from '@/film/PhotoFilm'; import { ReactNode } from 'react';
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 { useAppText } from '@/i18n/state/client';
import PhotoAvatar from '@/photo/PhotoAvatar';
import Link from 'next/link'; import Link from 'next/link';
import { PATH_ADMIN_ABOUT_EDIT } from '@/app/path'; import { PATH_ADMIN_ABOUT_EDIT } from '@/app/path';
import { LuCirclePlus, LuUser } from 'react-icons/lu'; import { LuCirclePlus } from 'react-icons/lu';
import AdminEmptyState from '@/admin/AdminEmptyState'; import AdminEmptyState from '@/admin/AdminEmptyState';
import { Place } from '@/place'; import AdminAboutMenu from './AdminAboutMenu';
import PlaceEntity from '@/place/PlaceEntity'; import clsx from 'clsx/lite';
export default function AboutPageClient({ export default function AboutPageClient({
title, title,
subhead, subhead,
descriptionHtml, descriptionHtml,
photosCount = 0,
photosOldest,
photoAvatar,
photoHero,
camera,
lens,
recipe,
film,
tag,
place,
album,
lastUpdated,
}: { }: {
title?: string title?: string
subhead?: string subhead?: string
descriptionHtml?: ReactNode descriptionHtml?: ReactNode
photosCount?: number
photosOldest?: string
photoAvatar?: Photo
photoHero?: Photo
camera?: Camera
lens?: Lens
recipe?: string
film?: string
tag?: string
place?: Place
album?: Album
lastUpdated?: Date
}) { }) {
const { const { isUserSignedIn } = useAppState();
isUserSignedIn,
} = useAppState();
const appText = useAppText(); 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}
/>,
),
tag && renderItem(
appText.about.popularTag,
<PhotoTag
tag={tag}
type="text-only"
contrast="high"
/>,
),
place && renderItem(
appText.about.popularPlace,
<PlaceEntity
place={place}
type="text-only"
contrast="high"
badged={false}
/>,
),
album && renderItem(
appText.about.recentAlbum,
<PhotoAlbum
album={album}
type="text-only"
contrast="high"
/>,
),
].filter(Boolean), [
appText.about,
photosCount,
photosOldest,
camera,
lens,
recipe,
film,
album,
place,
tag,
]);
return ( return (
<AnimateItems <AnimateItems
type="bottom" type="bottom"
items={[<div items={[<div
key="about-page" key="about-page"
className="space-y-12 mt-5" className="mt-5 max-w-2xl"
> >
<AppGrid <AppGrid
contentMain={<div className="space-y-8"> contentMain={<div className="space-y-6">
<div className="flex items-center gap-4 sm:gap-6"> <div className="flex items-start justify-between gap-4">
<PhotoAvatar <div className="space-y-2">
photo={photoAvatar} <h1 className="text-2xl font-bold">
placeholder={<LuUser size={22} className="text-dim" />} {title || appText.about.titleDefault}
/> </h1>
<div {subhead &&
className={clsx('sm:flex items-center justify-between grow')} <p className="text-medium">{subhead}</p>}
>
<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> </div>
{isUserSignedIn && <AdminAboutMenu />} {isUserSignedIn && <AdminAboutMenu />}
</div> </div>
@ -207,21 +58,10 @@ export default function AboutPageClient({
includeContainer={false} includeContainer={false}
className="gap-3! p-6!" className="gap-3! p-6!"
> >
Add optional description Add description
</AdminEmptyState> </AdminEmptyState>
</Link>} </Link>}
<AnimateItems
className={clsx(
'grid gap-x-2 gap-y-6 grid-cols-2',
items.length === 7 || items.length === 8
? 'lg:grid-cols-4'
: 'lg:grid-cols-3',
)}
items={items}
/>
</div>} /> </div>} />
{photoHero &&
<PhotoLarge photo={photoHero} />}
</div>]} </div>]}
/> />
); );

View File

@ -8,26 +8,13 @@ import FieldsetWithStatus from '@/components/FieldsetWithStatus';
import AdminChildPage from '@/components/AdminChildPage'; import AdminChildPage from '@/components/AdminChildPage';
import { updateAboutAction } from './actions'; import { updateAboutAction } from './actions';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { Photo } from '@/photo';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
import FieldsetPhotoChooser from '@/photo/form/FieldsetPhotoChooser'; import { ABOUT_DESCRIPTION_DEFAULT, ABOUT_SUBHEAD, ABOUT_TITLE } from '@/app/config';
import { ABOUT_DESCRIPTION_DEFAULT } from '@/app/config';
export default function AdminAboutEditPage({ export default function AdminAboutEditPage({
about, about,
photoAvatar,
photoHero,
photos,
photosCount,
photosFavs,
}: { }: {
about?: About about?: About
photoAvatar?: Photo
photoHero?: Photo
photos: Photo[]
photosCount: number
photosFavs: Photo[]
shouldResizeImages?: boolean
}) { }) {
const appText = useAppText(); const appText = useAppText();
@ -40,32 +27,21 @@ export default function AdminAboutEditPage({
breadcrumb="Edit About Page" breadcrumb="Edit About Page"
> >
<form <form
className="space-y-12 mt-12" className="space-y-8 mt-12 max-w-xl"
action={updateAboutAction} action={updateAboutAction}
> >
<div className="space-y-4"> <div className="space-y-4">
<FieldsetPhotoChooser
id="photoIdAvatar"
label="Avatar"
value={aboutForm?.photoIdAvatar ?? photoAvatar?.id ?? ''}
onChange={photoIdAvatar => setAboutForm(form =>
({ ...form, photoIdAvatar }))}
photo={photoAvatar}
photos={photos}
photosCount={photosCount}
photosFavs={photosFavs}
/>
<FieldsetWithStatus <FieldsetWithStatus
label="Title" label="Title"
value={aboutForm?.title ?? ''} value={aboutForm?.title ?? ''}
placeholder={appText.about.titleDefault} placeholder={ABOUT_TITLE || appText.about.titleDefault}
onChange={title => setAboutForm(form => onChange={title => setAboutForm(form =>
({ ...form, title }))} ({ ...form, title }))}
/> />
<FieldsetWithStatus <FieldsetWithStatus
label="Subhead" label="Subhead"
type={!aboutForm?.title ? 'hidden' : undefined}
value={aboutForm?.subhead ?? ''} value={aboutForm?.subhead ?? ''}
placeholder={ABOUT_SUBHEAD}
onChange={subhead => setAboutForm(form => onChange={subhead => setAboutForm(form =>
({ ...form, subhead }))} ({ ...form, subhead }))}
/> />
@ -77,17 +53,9 @@ export default function AdminAboutEditPage({
onChange={description => setAboutForm(form => onChange={description => setAboutForm(form =>
({ ...form, description }))} ({ ...form, description }))}
/> />
<FieldsetPhotoChooser <p className="text-dim text-sm">
id="photoIdHero" Supports simple formatting: &lt;b&gt;, &lt;i&gt;, &lt;u&gt;, &lt;br&gt;
label="Hero" </p>
value={aboutForm?.photoIdHero || photoHero?.id || ''}
onChange={photoIdHero => setAboutForm(form =>
({ ...form, photoIdHero }))}
photo={photoHero}
photos={photos}
photosCount={photosCount}
photosFavs={photosFavs}
/>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<LinkWithStatus <LinkWithStatus

View File

@ -1,37 +1,10 @@
import { getPhotoCached, getPhotosCached } from '@/photo/cache';
import { About } from '.';
import { TAG_FAVS } from '@/tag';
import { getAbout } from './query'; import { getAbout } from './query';
import { getAboutCached } from './cache'; import { getAboutCached } from './cache';
const getAboutAvatar = (about?: About) =>
about?.photoIdAvatar
? getPhotoCached(about?.photoIdAvatar ?? '', true)
: undefined;
const getAboutHero = (about?: About) =>
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]));
export const getAboutData = () => export const getAboutData = () =>
getAbout() getAbout()
.then(async about => ({ .then(about => ({ about }));
about,
photoAvatar: await getAboutAvatar(about),
photoHero: await getAboutHero(about),
}));
export const getAboutDataCached = () => export const getAboutDataCached = () =>
getAboutCached() getAboutCached()
.then(async about => ({ .then(about => ({ about }));
about,
photoAvatar: await getAboutAvatar(about),
photoHero: await getAboutHero(about),
}));

View File

@ -153,9 +153,17 @@ export const SIDEBAR_TEXT =
process.env.NEXT_PUBLIC_PAGE_ABOUT || process.env.NEXT_PUBLIC_PAGE_ABOUT ||
process.env.NEXT_PUBLIC_SITE_ABOUT; process.env.NEXT_PUBLIC_SITE_ABOUT;
export const ABOUT_TITLE =
process.env.NEXT_PUBLIC_ABOUT_TITLE ||
NAV_TITLE;
export const ABOUT_SUBHEAD =
process.env.NEXT_PUBLIC_ABOUT_SUBHEAD;
export const ABOUT_DESCRIPTION_DEFAULT = export const ABOUT_DESCRIPTION_DEFAULT =
process.env.NEXT_PUBLIC_META_DESCRIPTION || process.env.NEXT_PUBLIC_ABOUT_DESCRIPTION ||
process.env.NEXT_PUBLIC_SIDEBAR_TEXT; process.env.NEXT_PUBLIC_SIDEBAR_TEXT ||
process.env.NEXT_PUBLIC_META_DESCRIPTION;
// STORAGE // STORAGE