Compare commits

...

10 Commits

Author SHA1 Message Date
Strtus
fd8f12a7bc Fix TypeScript type for direct storage image flag.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 20:05:50 +08:00
Strtus
b9bef5e264 Load gallery images directly from R2 CDN, bypassing Next image proxy.
Use unoptimized next/image with pre-sized R2 URLs (-sm/-md/-lg) when a public R2 domain is configured. Falls back to the original file if a sized variant is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 20:03:47 +08:00
Strtus
69094071c8 Add Docker self-hosting configuration for standalone Next.js build.
Include Dockerfile, compose file, dockerignore, and next.config output standalone for server deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 17:35:53 +08:00
Strtus
8e6df1e70c 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>
2026-05-20 17:16:31 +08:00
Strtus
2315647743 Fix EdgeOne auth runtime configuration
Set trustHost and explicitly provide AUTH_SECRET in NextAuth config
so auth routes do not fail host/config validation on non-Vercel
platforms like EdgeOne.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 11:41:15 +08:00
Strtus
0ac550c039 Fix client-side Vercel Blob fallback never executing
HAS_VERCEL_BLOB_STORAGE depends on BLOB_READ_WRITE_TOKEN which is a
server-only env var (not NEXT_PUBLIC_*), so it evaluates to false in
the browser. When the R2 presigned-URL PUT fails (e.g. due to missing
CORS on the R2 bucket), the fallback branch was unreachable and the
original "Failed to fetch" error propagated to the UI.

vercelBlobUploadFromClient works client-side because it obtains the
token via a server round-trip to handleUploadUrl, so try it
unconditionally as a fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 10:54:33 +08:00
Strtus
66fa66d3d3 Skip auth middleware for public proxy routes
Route public paths through proxy without invoking auth so category pages no longer fail with runtime auth errors on EdgeOne.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 01:11:42 +08:00
Strtus
6a01efce18 Add static generation throttles for constrained build environments
Allow disabling static generation and tuning static params limits via env vars to reduce build output size on low-disk CI providers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:58:05 +08:00
Strtus
053a3b4acc Harden client uploads and add storage fallback
Validate presigned upload responses and fall back to Vercel Blob when third-party storage upload fails so photo uploads remain available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:54:46 +08:00
Strtus
d5001c9ee5 Use default export in Next config for EdgeOne compatibility
Switch next.config.ts from CommonJS export to default export so OpenNext wrappers can import the original config during type checking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:40:34 +08:00
17 changed files with 254 additions and 377 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
.next
.git
.github
.vercel
.vscode
__tests__
readme
*.md
.DS_Store
.env*.local

35
Dockerfile Normal file
View File

@ -0,0 +1,35 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NODE_OPTIONS="--max-old-space-size=1536"
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

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 />
); );
} }

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
services:
photo-blog:
build:
context: .
args:
- NODE_OPTIONS=--max-old-space-size=1536
extra_hosts:
- 'host.docker.internal:host-gateway'
container_name: photo-blog
restart: unless-stopped
ports:
- '127.0.0.1:3000:3000'
env_file:
- .env
extra_hosts:
- 'host.docker.internal:host-gateway'
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/']
interval: 30s
timeout: 10s
retries: 3

View File

@ -2,6 +2,7 @@ import { removeUrlProtocol } from '@/utility/url';
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import { RemotePattern } from 'next/dist/shared/lib/image-config'; import { RemotePattern } from 'next/dist/shared/lib/image-config';
import path from 'path'; import path from 'path';
import bundleAnalyzer from '@next/bundle-analyzer';
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i, /^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
@ -75,6 +76,7 @@ const IMAGE_QUALITY =
: 75; : 75;
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone',
images: { images: {
imageSizes: [200], imageSizes: [200],
qualities: [75, IMAGE_QUALITY], qualities: [75, IMAGE_QUALITY],
@ -96,6 +98,8 @@ const nextConfig: NextConfig = {
}, },
}; };
module.exports = process.env.ANALYZE === 'true' const withBundleAnalyzer = bundleAnalyzer({
? require('@next/bundle-analyzer')()(nextConfig) enabled: process.env.ANALYZE === 'true',
: nextConfig; });
export default withBundleAnalyzer(nextConfig);

View File

@ -8,6 +8,7 @@ import {
PATH_OG_SAMPLE, PATH_OG_SAMPLE,
PREFIX_PHOTO, PREFIX_PHOTO,
PREFIX_TAG, PREFIX_TAG,
isPathProtected,
} from './src/app/path'; } from './src/app/path';
export function proxy(req: NextRequest, res:NextResponse) { export function proxy(req: NextRequest, res:NextResponse) {
@ -33,6 +34,12 @@ export function proxy(req: NextRequest, res:NextResponse) {
)); ));
} }
// Avoid invoking auth middleware on public routes so
// downstream auth runtime differences don't 500 category pages.
if (!isPathProtected(pathname)) {
return NextResponse.next();
}
return auth( return auth(
req as unknown as NextApiRequest, req as unknown as NextApiRequest,
res as unknown as NextApiResponse, res as unknown as NextApiResponse,

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
@ -264,6 +272,14 @@ export const IMAGE_QUALITY =
process.env.NEXT_PUBLIC_IMAGE_QUALITY process.env.NEXT_PUBLIC_IMAGE_QUALITY
? parseInt(process.env.NEXT_PUBLIC_IMAGE_QUALITY) ? parseInt(process.env.NEXT_PUBLIC_IMAGE_QUALITY)
: 75; : 75;
// Load photos from storage CDN in the browser (skip /_next/image server proxy).
// Enabled by default when a public R2 domain is configured; set to 0 to disable.
export const DIRECT_STORAGE_IMAGES =
process.env.NEXT_PUBLIC_DIRECT_STORAGE_IMAGES === '1' ||
(
process.env.NEXT_PUBLIC_DIRECT_STORAGE_IMAGES !== '0' &&
Boolean(process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN)
);
export const BLUR_ENABLED = export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1'; process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';

View File

@ -12,6 +12,8 @@ import { getAllPublicPhotoIds } from '@/photo/query';
import { depluralize, pluralize } from '@/utility/string'; import { depluralize, pluralize } from '@/utility/string';
type StaticOutput = 'page' | 'image'; type StaticOutput = 'page' | 'image';
const STATIC_GENERATION_DISABLED =
process.env.DISABLE_STATIC_GENERATION === '1';
const logStaticGenerationDetails = (count: number, content: string) => { const logStaticGenerationDetails = (count: number, content: string) => {
if (count > 0) { if (count > 0) {
@ -21,9 +23,10 @@ const logStaticGenerationDetails = (count: number, content: string) => {
}; };
export const staticallyGeneratePhotosIfConfigured = (type: StaticOutput) => ( export const staticallyGeneratePhotosIfConfigured = (type: StaticOutput) => (
!STATIC_GENERATION_DISABLED && (
(type === 'page' && STATICALLY_OPTIMIZED_PHOTOS) || (type === 'page' && STATICALLY_OPTIMIZED_PHOTOS) ||
(type === 'image' && STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES) (type === 'image' && STATICALLY_OPTIMIZED_PHOTO_OG_IMAGES)
) ))
? async () => { ? async () => {
const photoIds = await getAllPublicPhotoIds({ const photoIds = await getAllPublicPhotoIds({
limit: GENERATE_STATIC_PARAMS_LIMIT, limit: GENERATE_STATIC_PARAMS_LIMIT,
@ -45,6 +48,7 @@ export const staticallyGenerateCategoryIfConfigured = <T, K>(
getData: () => Promise<T[]>, getData: () => Promise<T[]>,
formatData: (data: T[]) => K[], formatData: (data: T[]) => K[],
): (() => Promise<K[]>) | undefined => ): (() => Promise<K[]>) | undefined =>
!STATIC_GENERATION_DISABLED &&
CATEGORY_VISIBILITY.includes(key) && ( CATEGORY_VISIBILITY.includes(key) && (
(type === 'page' && STATICALLY_OPTIMIZED_PHOTO_CATEGORIES) || (type === 'page' && STATICALLY_OPTIMIZED_PHOTO_CATEGORIES) ||
(type === 'image' && STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES) (type === 'image' && STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES)

View File

@ -8,6 +8,10 @@ export const {
signOut, signOut,
auth, auth,
} = NextAuth({ } = NextAuth({
// Non-Vercel platforms (e.g. EdgeOne) may not be auto-trusted by Auth.js.
// Enabling trustHost prevents auth route 500s caused by host validation.
trustHost: true,
secret: process.env.AUTH_SECRET,
providers: [ providers: [
Credentials({ Credentials({
async authorize({ email, password }) { async authorize({ email, password }) {

View File

@ -1,8 +1,12 @@
'use client'; 'use client';
/* eslint-disable jsx-a11y/alt-text */ /* eslint-disable jsx-a11y/alt-text */
import { BLUR_ENABLED } from '@/app/config'; import { BLUR_ENABLED, DIRECT_STORAGE_IMAGES } from '@/app/config';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import {
getDirectPhotoDisplayUrl,
isExternalStoragePhotoUrl,
} from '@/photo/storage';
import { clsx} from 'clsx/lite'; import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image'; import Image, { ImageProps } from 'next/image';
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
@ -26,11 +30,31 @@ export default function ImageWithFallback({
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [didError, setDidError] = useState(false); const [didError, setDidError] = useState(false);
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>();
const [fadeFallbackTransition, setFadeFallbackTransition] = const [fadeFallbackTransition, setFadeFallbackTransition] =
useState(!hasLoadedWithAnimations); useState(!hasLoadedWithAnimations);
const srcString = typeof props.src === 'string' ? props.src : undefined;
const useDirectStorage = Boolean(
DIRECT_STORAGE_IMAGES &&
srcString &&
isExternalStoragePhotoUrl(srcString),
);
const displayWidth = typeof props.width === 'number' ? props.width : 640;
const directSrc = useDirectStorage && srcString && !fallbackSrc
? getDirectPhotoDisplayUrl(srcString, displayWidth)
: undefined;
const resolvedSrc = fallbackSrc ?? directSrc ?? props.src;
const onLoad = useCallback(() => setIsLoading(false), []); const onLoad = useCallback(() => setIsLoading(false), []);
const onError = useCallback(() => setDidError(true), []); const onError = useCallback(() => {
if (useDirectStorage && srcString && !fallbackSrc) {
setFallbackSrc(srcString);
setIsLoading(true);
} else {
setDidError(true);
}
}, [fallbackSrc, srcString, useDirectStorage]);
useEffect(() => { useEffect(() => {
if ( if (
@ -61,6 +85,8 @@ export default function ImageWithFallback({
> >
<Image ref={refProp ?? ref} {...{ <Image ref={refProp ?? ref} {...{
...props, ...props,
src: resolvedSrc,
unoptimized: useDirectStorage,
priority, priority,
className: classNameImage, className: classNameImage,
onLoad, onLoad,

View File

@ -9,7 +9,14 @@ import { getAlbumFromSlug } from '@/album/query';
import { isTagPrivate } from '@/tag'; import { isTagPrivate } from '@/tag';
import { getPhotoCount } from '@/photo/query'; import { getPhotoCount } from '@/photo/query';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000; const staticParamsLimitRaw = parseInt(
process.env.GENERATE_STATIC_PARAMS_LIMIT || '1000',
10,
);
export const GENERATE_STATIC_PARAMS_LIMIT =
Number.isFinite(staticParamsLimitRaw) && staticParamsLimitRaw > 0
? staticParamsLimitRaw
: 1000;
export const PHOTO_DEFAULT_LIMIT = 100; export const PHOTO_DEFAULT_LIMIT = 100;
// These must mirror utility/string.ts parameterization // These must mirror utility/string.ts parameterization

View File

@ -1,3 +1,4 @@
import { DIRECT_STORAGE_IMAGES } from '@/app/config';
import { import {
getNextImageUrlForRequest, getNextImageUrlForRequest,
NextImageSize, NextImageSize,
@ -9,6 +10,9 @@ import {
getStorageUrlsForPrefix, getStorageUrlsForPrefix,
uploadFileFromClient, uploadFileFromClient,
} from '@/platforms/storage'; } from '@/platforms/storage';
import { isUrlFromAwsS3 } from '@/platforms/storage/aws-s3';
import { isUrlFromCloudflareR2 } from '@/platforms/storage/cloudflare-r2';
import { isUrlFromMinio } from '@/platforms/storage/minio';
import { Photo } from '..'; import { Photo } from '..';
import { fetchBase64ImageFromUrl } from '@/utility/image'; import { fetchBase64ImageFromUrl } from '@/utility/image';
@ -98,12 +102,36 @@ const getSuffixFromNextImageSize = (nextSize: NextImageSize) =>
OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix
?? OPTIMIZED_SUFFIX_DEFAULT; ?? OPTIMIZED_SUFFIX_DEFAULT;
export const isExternalStoragePhotoUrl = (url: string) =>
isUrlFromCloudflareR2(url) ||
isUrlFromAwsS3(url) ||
isUrlFromMinio(url);
const getNextImageSizeForDisplayWidth = (displayWidth: number): NextImageSize => {
if (displayWidth <= 200) { return 200; }
if (displayWidth <= 640) { return 640; }
if (displayWidth <= 1080) { return 1080; }
if (displayWidth <= 1200) { return 1200; }
return 1920;
};
/** Public CDN URL for a photo (e.g. R2 -md/-sm), not /_next/image. */
export const getDirectPhotoDisplayUrl = (
imageUrl: string,
displayWidth: number,
) =>
getOptimizedPhotoUrl({
imageUrl,
size: getNextImageSizeForDisplayWidth(displayWidth),
useNextImage: false,
});
export const getOptimizedPhotoUrl = ( export const getOptimizedPhotoUrl = (
args: Parameters<typeof getNextImageUrlForRequest>[0] & { args: Parameters<typeof getNextImageUrlForRequest>[0] & {
useNextImage?: boolean useNextImage?: boolean
}, },
) => { ) => {
const { useNextImage = true } = args; const { useNextImage = !DIRECT_STORAGE_IMAGES } = args;
const suffix = getSuffixFromNextImageSize(args.size); const suffix = getSuffixFromNextImageSize(args.size);
const { const {
urlBase, urlBase,

View File

@ -119,11 +119,27 @@ export const uploadFromClientViaPresignedUrl = async (
file: File | Blob, file: File | Blob,
fileName: string, fileName: string,
) => { ) => {
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${fileName}`) const response = await fetch(`${PATH_API_PRESIGNED_URL}/${fileName}`);
.then((response) => response.text()); if (!response.ok) {
throw new Error(
`Failed to get presigned URL: ${response.status} ${response.statusText}`,
);
}
return fetch(url, { method: 'PUT', body: file }) const url = (await response.text()).trim();
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${fileName}`);
if (!/^https?:\/\//i.test(url)) {
throw new Error('Invalid presigned URL response');
}
const uploadResponse = await fetch(url, { method: 'PUT', body: file });
if (!uploadResponse.ok) {
throw new Error(
`Failed to upload with presigned URL: ${uploadResponse.status} ${uploadResponse.statusText}`,
);
}
return `${baseUrlForStorage(CURRENT_STORAGE)}/${fileName}`;
}; };
export const uploadFileFromClient = async ( export const uploadFileFromClient = async (
@ -136,13 +152,27 @@ export const uploadFileFromClient = async (
? `${_fileName}-${generateStorageId()}.${extension}` ? `${_fileName}-${generateStorageId()}.${extension}`
: `${_fileName}.${extension}`; : `${_fileName}.${extension}`;
return ( if (
CURRENT_STORAGE === 'cloudflare-r2' || CURRENT_STORAGE === 'cloudflare-r2' ||
CURRENT_STORAGE === 'aws-s3' || CURRENT_STORAGE === 'aws-s3' ||
CURRENT_STORAGE === 'minio' CURRENT_STORAGE === 'minio'
) ) {
? uploadFromClientViaPresignedUrl(file, fileName) try {
: vercelBlobUploadFromClient(file, fileName); return await uploadFromClientViaPresignedUrl(file, fileName);
} catch (error) {
// HAS_VERCEL_BLOB_STORAGE relies on BLOB_READ_WRITE_TOKEN which is a
// server-only variable (not NEXT_PUBLIC_*), so it is always false in
// the browser. vercelBlobUploadFromClient only needs the server-side
// token via handleUploadUrl, so try it unconditionally as a fallback.
try {
return await vercelBlobUploadFromClient(file, fileName);
} catch {
throw error;
}
}
}
return vercelBlobUploadFromClient(file, fileName);
}; };
export const putFile = ( export const putFile = (