Compare commits
10 Commits
2c630ebd50
...
fd8f12a7bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8f12a7bc | ||
|
|
b9bef5e264 | ||
|
|
69094071c8 | ||
|
|
8e6df1e70c | ||
|
|
2315647743 | ||
|
|
0ac550c039 | ||
|
|
66fa66d3d3 | ||
|
|
6a01efce18 | ||
|
|
053a3b4acc | ||
|
|
d5001c9ee5 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.vercel
|
||||||
|
.vscode
|
||||||
|
__tests__
|
||||||
|
readme
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
|
.env*.local
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal 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"]
|
||||||
@ -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,
|
|
||||||
}} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
21
docker-compose.yml
Normal 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
|
||||||
@ -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);
|
||||||
|
|||||||
7
proxy.ts
7
proxy.ts
@ -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,
|
||||||
|
|||||||
@ -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" />}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx('sm:flex items-center justify-between grow')}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold">
|
|
||||||
{title || appText.about.titleDefault}
|
{title || appText.about.titleDefault}
|
||||||
</div>
|
</h1>
|
||||||
{subhead &&
|
{subhead &&
|
||||||
<div>{subhead}</div>}
|
<p className="text-medium">{subhead}</p>}
|
||||||
</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>]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: <b>, <i>, <u>, <br>
|
||||||
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
|
||||||
|
|||||||
@ -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),
|
|
||||||
}));
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user