This commit is contained in:
Sam Becker 2023-09-05 09:00:57 -05:00
commit df11a86181
108 changed files with 8356 additions and 0 deletions

38
.eslintrc.json Normal file
View File

@ -0,0 +1,38 @@
{
"extends": "next/core-web-vitals",
"plugins": ["@typescript-eslint"],
"rules": {
"@next/next/no-img-element": "off",
"@typescript-eslint/no-unused-expressions": ["warn"],
"@typescript-eslint/no-unused-vars": [
"warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"comma-dangle": [
"warn",
"always-multiline"
],
"indent": [
"warn",
2
],
"linebreak-style": [
"warn",
"unix"
],
"quotes": [
"warn",
"single"
],
"semi": [
"warn",
"always"
],
"max-len": [
"warn",
{ "code": 80 }
]
}
}

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# temp files
temp.jpg

21
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,21 @@
{
"cSpell.words": [
"ARROWLEFT",
"ARROWRIGHT",
"camelcase",
"exif",
"hgetall",
"hset",
"nextjs",
"qaub",
"skippable",
"thephotoblog",
"trpc",
"WRHGZC",
"zadd",
"zrange"
],
"files.associations": {
"*.css": "tailwindcss"
}
}

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# 📸 Photo Blog `(BETA)`
_This template is in `BETA`. Optimizations still being made around auth and cache behavior. Database schema changes to be expected._
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Photo+Blog&demo-description=Store+photos+with+original+camera+data&demo-url=https%3A%2F%2Fphotos.sambecker.com&demo-image=https%3A%2F%2Fphotos.sambecker.com%2Fdeploy-image&project-name=Photo+Blog&repository-name=photo-blog&repository-url=https%3A%2F%2Fgithub.com%2Fsambecker%2Fphoto-blog&from=templates&skippable-integrations=1&env-description=Configure+your+photo+blog+meta&env-link=BLANK&env=NEXT_PUBLIC_SITE_TITLE%2CNEXT_PUBLIC_SITE_DOMAIN&teamCreateStatus=hidden&stores=%5B%7B%22type%22%3A%22postgres%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D)
### 1. Deploy to Vercel
1. Click Deploy
2. Add required storage
3. Add environment variables
- `NEXT_PUBLIC_SITE_TITLE` (e.g., My Photos)
- `NEXT_PUBLIC_SITE_DOMAIN` (e.g., photos.domain.com)
- `NEXT_PUBLIC_SITE_DESCRIPTION` (optional—mainly used for og meta)
### 2. Setup Vercel Postgres
1. Visit the `Storage` tab on your project
2. Click "Create Database"
3. Select Postgres
### 3. Setup Vercel Blob
1. Visit the `Storage` tab on your project
2. Click "Create Database"
3. Select Blob
### 4. Setup Auth
1. Create a Clerk account
2. Add Clerk environment variables to your project
3. Create an admin user
4. Add your admin user id to your environment variables as
- `CLERK_ADMIN_USER_ID`
### 5. Develop locally
1. Clone code
2. Install dependencies `pnpm i`
3. Run `vc dev` to utilize Vercel-stored environment variables

22
next.config.js Normal file
View File

@ -0,0 +1,22 @@
/** @type {import('next').NextConfig} */
const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
)?.[1].toLowerCase();
const nextConfig = {
images: {
imageSizes: [400, 1050, 1200],
remotePatterns: [{
protocol: 'https',
hostname: `${STORE_ID}.public.blob.vercel-storage.com`,
port: '',
pathname: '/**',
}],
},
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "photo-blog",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@clerk/nextjs": "^4.23.4",
"@tailwindcss/forms": "^0.5.6",
"@types/node": "^20.5.9",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@vercel/analytics": "^1.0.2",
"@vercel/blob": "^0.10.0",
"@vercel/og": "^0.5.13",
"@vercel/postgres": "^0.4.1",
"autoprefixer": "10.4.15",
"camelcase-keys": "^9.0.0",
"date-fns": "^2.30.0",
"eslint": "8.48.0",
"eslint-config-next": "13.4.19",
"framer-motion": "^10.16.3",
"next": "^13.4.19",
"next-themes": "^0.2.1",
"postcss": "8.4.29",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.10.1",
"react-spinners": "^0.13.8",
"short-uuid": "^4.2.2",
"tailwindcss": "3.3.3",
"ts-exif-parser": "^0.2.2",
"typescript": "5.2.2"
}
}

4041
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicons/dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

BIN
public/favicons/light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
public/fonts/OFL.txt Normal file
View File

@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,23 @@
import PhotoForm from '@/photo/PhotoForm';
import { convertPhotoToFormData } from '@/photo/form';
import AdminChildPage from '@/components/AdminChildPage';
import { getPhoto } from '@/services/postgres';
export const runtime = 'edge';
interface Props {
params: { photoId: string }
}
export default async function PhotoPageEdit({ params: { photoId } }: Props) {
const photo = await getPhoto(photoId);
return (
<AdminChildPage>
<PhotoForm
type="edit"
initialPhotoForm={convertPhotoToFormData(photo)}
/>
</AdminChildPage>
);
};

View File

@ -0,0 +1,185 @@
import { Fragment, ReactNode } from 'react';
import PhotoUploadInput from '@/photo/PhotoUploadInput';
import Link from 'next/link';
import PhotoTiny from '@/photo/PhotoTiny';
import { cc } from '@/utility/css';
import ImageTiny from '@/components/ImageTiny';
import FormWithConfirm from '@/components/FormWithConfirm';
import SiteGrid from '@/components/SiteGrid';
import {
deletePhotoAction,
deleteBlobPhotoAction,
} from '@/photo/actions';
import { FaRegEdit } from 'react-icons/fa';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import {
pathForBlobUrl,
getBlobPhotoUrls,
getBlobUploadUrls,
} from '@/services/blob';
import { getPhotos } from '@/services/postgres';
import { routeForPhoto } from '@/site/routes';
export const runtime = 'edge';
const DEBUG_PHOTO_BLOBS = false;
export default async function AdminPage() {
const photos = await getPhotos('createdAt');
const blobUploadUrls = await getBlobUploadUrls();
const blobPhotoUrls = DEBUG_PHOTO_BLOBS
? await getBlobPhotoUrls()
: [];
return (
<SiteGrid
contentMain={
<div className="mt-4 space-y-4">
<div className="space-y-8">
<PhotoUploadInput />
{blobUploadUrls.length > 0 &&
<BlobUrls
blobUrls={blobUploadUrls}
label={`Uploads Files (${blobUploadUrls.length})`}
/>}
{blobPhotoUrls.length > 0 &&
<BlobUrls
blobUrls={blobPhotoUrls}
label={`Photos Files (${blobPhotoUrls.length})`}
/>}
<AdminGrid title={`Photos (${photos.length})`}>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny
className={cc(
'rounded-sm overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
photo={photo}
/>
<Link
key={photo.id}
href={routeForPhoto(photo)}
className="grow flex items-center gap-2"
>
{photo.title}
{photo.priorityOrder !== null &&
<span className={cc(
'text-xs leading-none px-1.5 py-1 rounded-sm',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className="text-gray-400 uppercase">
{photo.takenAtNaive}
</div>
<EditButton href={`/admin/photos/${photo.idShort}/edit`} />
<FormWithConfirm
action={deletePhotoAction}
confirmText={
`Are you sure you want to delete "${photo.title}?"`}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton />
</FormWithConfirm>
</Fragment>)}
</AdminGrid>
</div>
</div>}
/>
);
}
function AdminGrid ({
title,
children,
}: {
title: string,
children: ReactNode,
}) {
return <div className="space-y-2">
<div className="font-bold">
{title}
</div>
<div className="min-w-[14rem] overflow-x-scroll">
<div className={cc(
'w-full',
'grid grid-cols-[auto_auto_1fr_auto_auto] ',
'gap-3 items-center',
)}>
{children}
</div>
</div>
</div>;
}
function EditButton ({
href,
label = 'Edit',
}: {
href: string,
label?: string,
}) {
return <Link
href={href}
className="button"
>
<FaRegEdit className="translate-y-[-0.5px]" />
{label}
</Link>;
}
function DeleteButton () {
return <SubmitButtonWithStatus
icon={<span className="inline-flex text-[18px]">×</span>}
>
Delete
</SubmitButtonWithStatus>;
}
function BlobUrls ({
blobUrls,
label,
}: {
blobUrls: string[],
label: string,
}) {
return <AdminGrid title={label}>
{blobUrls.map(url => {
const href = `/admin/uploads/${encodeURIComponent(url)}`;
const fileName = url.split('/').pop();
return <Fragment key={url}>
<Link href={href}>
<ImageTiny
alt={`Photo: ${fileName}`}
src={url}
aspectRatio={3.0 / 2.0}
className={cc(
'rounded-sm overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
/>
</Link>
<Link
href={href}
className="break-all"
title={url}
>
{pathForBlobUrl(url)}
</Link>
<div />
<EditButton href={href} label="Setup" />
<FormWithConfirm
action={deleteBlobPhotoAction}
confirmText="Are you sure you want to delete this upload?"
>
<input type="hidden" name="url" value={url} />
<DeleteButton />
</FormWithConfirm>
</Fragment>;})}
</AdminGrid>;
}

View File

@ -0,0 +1,42 @@
import PhotoForm from '@/photo/PhotoForm';
import { ExifParserFactory } from 'ts-exif-parser';
import { convertExifToFormData } from '@/photo/form';
import AdminChildPage from '@/components/AdminChildPage';
import { getExtensionFromBlobUrl } from '@/services/blob';
interface Params {
params: { uploadPath: string }
}
export default async function UploadPage({ params: { uploadPath } }: Params) {
const url = decodeURIComponent(uploadPath);
const extension = getExtensionFromBlobUrl(url);
const fileBytes = uploadPath
? await fetch(url)
.then(res => res.arrayBuffer())
: undefined;
let data;
if (fileBytes) {
data = ExifParserFactory
.create(Buffer.from(fileBytes))
.parse();
}
return (
<AdminChildPage>
{data
? <PhotoForm
initialPhotoForm={{
extension,
url: decodeURIComponent(uploadPath),
...convertExifToFormData(data),
}}
/>
: null}
</AdminChildPage>
);
};

View File

@ -0,0 +1,39 @@
import {
ACCEPTED_PHOTO_FILE_TYPES,
isUploadPathnameValid,
} from '@/services/blob';
import { handleBlobUpload, type HandleBlobUploadBody } from '@vercel/blob';
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function POST(request: Request): Promise<NextResponse> {
const body = (await request.json()) as HandleBlobUploadBody;
try {
const jsonResponse = await handleBlobUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => {
if (isUploadPathnameValid(pathname)) {
return {
maximumSizeInBytes: 40_000_000,
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
};
} else {
throw new Error('Invalid upload');
}
},
onUploadCompleted: async () => {
revalidatePath('admin/photos');
},
});
return NextResponse.json(jsonResponse);
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 400 },
);
}
}

View File

@ -0,0 +1,14 @@
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import { SITE_CHECKLIST_STATUS } from '@/site';
import SiteChecklist from '@/site/SiteChecklist';
export default function ChecklistPage() {
return (
<SiteGrid
contentMain={<InfoBlock>
<SiteChecklist {...SITE_CHECKLIST_STATUS} />
</InfoBlock>}
/>
);
}

View File

@ -0,0 +1,17 @@
import AuthNav from '@/components/AuthNav';
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<ClerkProvider>
{children}
<div className="my-8">
<AuthNav />
</div>
</ClerkProvider>
);
}

View File

@ -0,0 +1,16 @@
import { cc } from '@/utility/css';
import { SignIn } from '@clerk/nextjs';
export const runtime = 'edge';
export default function SignInPage() {
return (
<div className={cc(
'fixed top-0 left-0 right-0 bottom-0',
'flex items-center justify-center',
'translate-x-2',
)}>
<SignIn />
</div>
);
}

View File

@ -0,0 +1,42 @@
import DeployImageResponse from '@/photo/image-response/DeployImageResponse';
import { getPhotos } from '@/services/postgres';
import { GRID_OG_WIDTH, GRID_OG_HEIGHT } from '@/site';
import { FONT_FAMILY_IBM_PLEX_MONO, getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from '@vercel/og';
const DEBUG_CACHING: boolean = false;
export const runtime = 'edge';
export async function GET(request: Request) {
const photos = await getPhotos('priority');
const fontData = await getIBMPlexMonoMedium();
return new ImageResponse(
(
<DeployImageResponse {...{
photos,
request,
width: GRID_OG_WIDTH,
height: GRID_OG_HEIGHT,
fontFamily: FONT_FAMILY_IBM_PLEX_MONO,
}}/>
),
{
width: GRID_OG_WIDTH,
height: GRID_OG_HEIGHT,
fonts: [
{
name: FONT_FAMILY_IBM_PLEX_MONO,
data: fontData,
style: 'normal',
},
],
...!DEBUG_CACHING && {
headers: {
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
},
},
},
);
}

View File

@ -0,0 +1,34 @@
/* eslint-disable max-len */
import { NextResponse } from 'next/server';
const TITLE = 'Photo Blog';
const DESCRIPTION = 'Store photos with original camera data';
const REPO_NAME = 'photo-blog';
export function GET() {
const url = new URL('https://vercel.com/new/clone');
url.searchParams.set('demo-title', TITLE);
url.searchParams.set('demo-description', DESCRIPTION);
url.searchParams.set('demo-url', 'https://photos.sambecker.com');
url.searchParams.set('demo-description', DESCRIPTION);
url.searchParams.set('demo-image', 'https://photos.sambecker.com/deploy-image');
url.searchParams.set('project-name', TITLE);
url.searchParams.set('repository-name', REPO_NAME);
url.searchParams.set('repository-url', `https://github.com/sambecker/${REPO_NAME}`);
url.searchParams.set('from', 'templates');
url.searchParams.set('skippable-integrations', '1');
url.searchParams.set('env-description', 'Configure your photo blog meta');
url.searchParams.set('env-link', 'BLANK');
url.searchParams.set('env', [
'NEXT_PUBLIC_SITE_TITLE',
'NEXT_PUBLIC_SITE_DOMAIN',
].join(','));
url.searchParams.set('teamCreateStatus', 'hidden');
url.searchParams.set('stores', JSON.stringify([
{ type: 'postgres' },
{ type: 'blob' },
]));
return NextResponse.json(url.toString());
}

View File

@ -0,0 +1,29 @@
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotos } from '@/services/postgres';
export const runtime = 'edge';
const PHOTOS_PER_PAGE = 6;
export default async function GridPage(
{ params }: { params: Record<string, string> }
) {
const offset = parseInt(params.offset ?? '0');
const photos = await getPhotos(
undefined,
PHOTOS_PER_PAGE,
Number.isNaN(offset) ? 0 : offset,
);
return (
<SiteGrid
contentMain={<PhotoGrid
photos={photos}
offset={offset}
staggerOnFirstLoadOnly
/>}
/>
);
}

View File

@ -0,0 +1,29 @@
import SiteGrid from '@/components/SiteGrid';
import { generateImageMetaForPhoto } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { getPhotos } from '@/services/postgres';
import { Metadata } from 'next';
export const runtime = 'edge';
export const dynamic = 'force-static';
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotos();
return generateImageMetaForPhoto(photos[0]);
}
export default async function GridPage() {
const photos = await getPhotos();
return (
photos.length > 0
? <SiteGrid
contentMain={<PhotoGrid
photos={photos}
staggerOnFirstLoadOnly
/>}
/>
: <PhotosEmptyState />
);
}

33
src/app/(isr)/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import SiteGrid from '@/components/SiteGrid';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import { cc } from '@/utility/css';
import Link from 'next/link';
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
{children}
<SiteGrid
contentMain={<div className={cc(
'my-8',
'flex items-center',
'text-gray-400 dark:text-gray-500',
)}>
<div className="flex-grow">
<Link
href="/admin/photos"
className="hover:text-gray-600 dark:hover:text-gray-400"
>
Admin
</Link>
</div>
<ThemeSwitcher />
</div>}
/>
</>
);
}

14
src/app/(isr)/og/page.tsx Normal file
View File

@ -0,0 +1,14 @@
import StaggeredPhotos from '@/photo/StaggeredPhotos';
import { getPhotos } from '@/services/postgres';
export const runtime = 'edge';
export default async function GridPage() {
const photos = await getPhotos();
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<StaggeredPhotos photos={photos} />
</div>
);
}

36
src/app/(isr)/page.tsx Normal file
View File

@ -0,0 +1,36 @@
import AnimateItems from '@/components/AnimateItems';
import { generateImageMetaForPhoto } from '@/photo';
import PhotoLarge from '@/photo/PhotoLarge';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { getPhotos } from '@/services/postgres';
import { Metadata } from 'next';
export const runtime = 'edge';
export const dynamic = 'force-static';
export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotos();
return generateImageMetaForPhoto(photos[0]);
}
export default async function HomePage() {
const photos = await getPhotos();
return (
photos.length > 0
? <AnimateItems
className="space-y-2"
duration={0.7}
staggerDelay={0.15}
distanceOffset={0}
staggerOnFirstLoadOnly
items={photos.map((photo, index) =>
<PhotoLarge
key={photo.id}
photo={photo}
priority={index <= 1}
/>)}
/>
: <PhotosEmptyState />
);
}

View File

@ -0,0 +1,42 @@
import PhotoOGImageResponse from '@/photo/image-response/PhotoOGImageResponse';
import { getPhoto } from '@/services/postgres';
import { IMAGE_OG_WIDTH, IMAGE_OG_HEIGHT } from '@/site';
import { FONT_FAMILY_IBM_PLEX_MONO, getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from '@vercel/og';
const DEBUG_CACHING: boolean = false;
export const runtime = 'edge';
export async function GET(request: Request, context: any) {
const photo = await getPhoto(context.params.photoId);
const fontData = await getIBMPlexMonoMedium();
return new ImageResponse(
(
<PhotoOGImageResponse
photo={photo}
requestOrPhotoPath={request}
width={IMAGE_OG_WIDTH}
height={IMAGE_OG_HEIGHT}
fontFamily={FONT_FAMILY_IBM_PLEX_MONO}
/>
),
{
width: IMAGE_OG_WIDTH,
height: IMAGE_OG_HEIGHT,
fonts: [
{
name: FONT_FAMILY_IBM_PLEX_MONO,
data: fontData,
weight: 500,
style: 'normal',
},
],
...!DEBUG_CACHING && {
headers: {
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
},
},
},
);
}

View File

@ -0,0 +1,81 @@
import { PropsWithChildren } from 'react';
import AnimateItems from '@/components/AnimateItems';
import PhotoLinks from '@/photo/PhotoLinks';
import SiteGrid from '@/components/SiteGrid';
import { ogImageDescriptionForPhoto, ogImageUrlForPhoto } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotoLarge from '@/photo/PhotoLarge';
import { cc } from '@/utility/css';
import { Metadata } from 'next';
import { BASE_URL } from '@/site/config';
import { getPhoto, getPhotos } from '@/services/postgres';
export const runtime = 'edge';
interface Props extends PropsWithChildren {
params: { photoId: string }
}
export async function generateMetadata(
{ params: { photoId } }: Props
): Promise<Metadata> {
const photo = await getPhoto(photoId);
const title = photo.title;
const description = ogImageDescriptionForPhoto(photo);
const images = ogImageUrlForPhoto(photo);
return {
title,
description,
openGraph: {
title,
images,
description,
url: `${BASE_URL}/photos/${photo.idShort}`,
},
twitter: {
title,
description,
images,
card: 'summary_large_image',
},
};
}
export default async function PhotoPage({
params: { photoId },
children,
}: Props) {
const photo = await getPhoto(photoId);
const photos = await getPhotos();
return <>
{children}
<div className="md:space-y-8">
<AnimateItems
animateFromAppState
items={[<PhotoLarge
key={photo.id}
photo={photo}
priority
showShare
/>]}
/>
<SiteGrid
sideFirstOnMobile
contentMain={<PhotoGrid
photos={photos}
selectedPhoto={photo}
animateOnFirstLoadOnly
staggerOnFirstLoadOnly
/>}
contentSide={<div className={cc(
'grid grid-cols-2',
'md:flex md:gap-4',
'user-select-none',
)}>
<PhotoLinks photo={photo} photos={photos} />
</div>}
/>
</div>
</>;
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View File

@ -0,0 +1,13 @@
import PhotoModal from '@/photo/PhotoModal';
import { getPhoto } from '@/services/postgres';
export const runtime = 'edge';
interface Props {
params: { photoId: string }
}
export default async function Share({ params: { photoId }}: Props) {
const photo = await getPhoto(photoId);
return <PhotoModal photo={photo} />;
}

83
src/app/layout.tsx Normal file
View File

@ -0,0 +1,83 @@
import { Analytics } from '@vercel/analytics/react';
import { cc } from '@/utility/css';
import { IBM_Plex_Mono } from 'next/font/google';
import { Metadata } from 'next';
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
import StateProvider from '@/state/AppStateProvider';
import ThemeProviderClient from '@/site/ThemeProviderClient';
import Nav from '@/components/Nav';
import '../site/globals.css';
const ibmPlexMono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-ibm-plex-mono',
});
export const metadata: Metadata = {
title: SITE_TITLE,
description: SITE_DESCRIPTION,
metadataBase: new URL(BASE_URL),
openGraph: {
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
twitter: {
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
icons: [{
url: '/favicon.ico',
rel: 'icon',
type: 'image/png',
sizes: '180x180',
}, {
url: '/favicons/light.png',
rel: 'icon',
media: '(prefers-color-scheme: light)',
type: 'image/png',
sizes: '32x32',
}, {
url: '/favicons/dark.png',
rel: 'icon',
media: '(prefers-color-scheme: dark)',
type: 'image/png',
sizes: '32x32',
}, {
url: '/favicons/apple-touch-icon.png',
rel: 'icon',
type: 'image/png',
sizes: '180x180',
}],
};
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html
lang="en"
// Suppress hydration errors due to
// next-themes behavior
suppressHydrationWarning
>
<body className={ibmPlexMono.variable}>
<ThemeProviderClient>
<main className={cc(
'px-3 pb-3',
'lg:px-6 lg:pb-6',
)}>
<Nav />
<StateProvider>
{children}
</StateProvider>
<Analytics />
</main>
</ThemeProviderClient>
</body>
</html>
);
}

42
src/cache/index.ts vendored Normal file
View File

@ -0,0 +1,42 @@
import { getPhoto, getPhotos } from '@/services/postgres';
import { revalidatePath, revalidateTag, unstable_cache } from 'next/cache';
const TAG_PHOTOS = 'photos';
const PHOTO_PATHS = [
'/',
'/grid',
'/photos/[photoId]',
'/photos/[photoId]/image',
'/admin/photos',
'/admin/photos/[photoId]',
'/admin/photos/[photoId]/edit',
];
const tagForPhoto = (photoId: string) => `photo-${photoId}`;
export const revalidatePhotosTag = (
includePhotoPaths?: boolean
) => {
revalidateTag(TAG_PHOTOS);
if (includePhotoPaths) { revalidateAllPhotoPaths(); }
};
export const revalidateAllPhotoPaths = () =>
PHOTO_PATHS.forEach(path => revalidatePath(path));
export const getPhotosCached: typeof getPhotos = (...args) =>
unstable_cache(
() => getPhotos(...args),
[TAG_PHOTOS], {
tags: [TAG_PHOTOS],
}
)();
export const getPhotoCached: typeof getPhoto = (...args) =>
unstable_cache(
() => getPhoto(...args),
[TAG_PHOTOS, tagForPhoto(...args)], {
tags: [TAG_PHOTOS, tagForPhoto(...args)],
}
)();

View File

@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import Link from 'next/link';
import { FiArrowLeft } from 'react-icons/fi';
function AdminChildPage({
children,
}: {
children: ReactNode,
}) {
return (
<div className="space-y-5">
<Link href="/admin/photos" className="flex gap-1 items-center">
<FiArrowLeft size={16} />
Admin
</Link>
<div>
{children}
</div>
</div>
);
};
export default AdminChildPage;

View File

@ -0,0 +1,120 @@
'use client';
import { useRef } from 'react';
import { motion } from 'framer-motion';
import { useAppState } from '@/state';
export type AnimationType = 'none' | 'scale' | 'left' | 'right';
export interface AnimationConfig {
type?: AnimationType
duration?: number
staggerDelay?: number
scaleOffset?: number
distanceOffset?: number
}
interface Props extends AnimationConfig {
className?: string
items: JSX.Element[]
animateFromAppState?: boolean
animateOnFirstLoadOnly?: boolean
staggerOnFirstLoadOnly?: boolean
}
function AnimateItems({
className,
items,
type = 'scale',
duration = 0.6,
staggerDelay = 0.1,
scaleOffset = 0.9,
distanceOffset = 20,
animateFromAppState,
animateOnFirstLoadOnly,
staggerOnFirstLoadOnly,
}: Props) {
const {
hasLoaded,
nextPhotoAnimation,
clearNextPhotoAnimation,
} = useAppState();
const hasLoadedInitial = useRef(hasLoaded);
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
const shouldAnimate = type !== 'none' &&
!(animateOnFirstLoadOnly && hasLoadedInitial.current);
const shouldStagger =
!(staggerOnFirstLoadOnly && hasLoadedInitial.current);
const typeResolved = animateFromAppState
? (nextPhotoAnimationInitial.current?.type ?? type)
: type;
const durationResolved = animateFromAppState
? (nextPhotoAnimationInitial.current?.duration ?? duration)
: duration;
const getInitialVariant = () => {
switch (typeResolved) {
case 'left': return {
opacity: 0,
translateX: distanceOffset,
};
case 'right': return {
opacity: 0,
translateX: -distanceOffset,
};
default: return {
opacity: 0,
scale: scaleOffset,
translateY: distanceOffset,
};
}
};
return (
<motion.div
className={className}
initial={shouldAnimate ? 'hidden' : false}
animate="show"
variants={shouldStagger
? {
show: {
transition: {
staggerChildren: staggerDelay,
},
},
} : undefined}
onAnimationComplete={() => {
if (animateFromAppState) {
clearNextPhotoAnimation?.();
}
}}
>
{items.map((item, index) =>
<motion.div
key={index}
style={getInitialVariant()}
variants={{
hidden: getInitialVariant(),
show: {
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
},
}}
transition={{
duration: durationResolved,
easing: 'easeOut',
}}
>
{item}
</motion.div>)}
</motion.div>
);
};
export default AnimateItems;

View File

@ -0,0 +1,55 @@
'use client';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { useClerk } from '@clerk/nextjs';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import SiteGrid from './SiteGrid';
import { usePathname } from 'next/navigation';
import { isRouteSignIn } from '@/site/routes';
const LINK_STYLE = cc(
'cursor-pointer',
'hover:text-gray-600',
);
export default function AuthNav() {
const { user, signOut } = useClerk();
const hasState = signOut !== undefined;
const path = usePathname();
return (
<SiteGrid
contentMain={<div className={cc(
'flex items-center',
'text-gray-400 dark:text-gray-500',
)}>
<div className="flex gap-4 flex-grow">
{hasState
? <>
{user === undefined &&
<>Loading ...</>}
{user !== undefined && user !== null && <>
<div>{user?.emailAddresses[0].emailAddress}</div>
<div
onClick={() => signOut()}
className={LINK_STYLE}
>
Sign Out
</div>
</>}
</>
: <Link
href="/sign-in"
className={LINK_STYLE}
>
Sign In
</Link>}
</div>
{!isRouteSignIn(path) && <ThemeSwitcher />}
</div>}
/>
);
};

View File

@ -0,0 +1,40 @@
export default function FieldSet({
id,
label,
value,
onChange,
required,
readOnly,
}: {
id: string
label: string
value: string
onChange?: (value: string) => void
required?: boolean
readOnly?: boolean
}) {
return (
<div className="space-y-1">
<label
className="flex gap-2"
htmlFor={id}
>
{label}
{required &&
<span className="text-gray-400 dark:text-gray-600">
(Required)
</span>}
</label>
<input
id={id}
name={id}
value={value}
onChange={e => onChange?.(e.target.value)}
type="text"
autoComplete="off"
readOnly={readOnly}
className="w-full"
/>
</div>
);
};

View File

@ -0,0 +1,28 @@
'use client';
import { ReactNode } from 'react';
export default function FormWithConfirm({
action,
confirmText,
children,
}: {
action: (data: FormData) => Promise<void>
confirmText: string
children: ReactNode
}) {
return (
<form
action={action}
onSubmit={e => {
if (!confirm(confirmText)) {
e.preventDefault();
} else {
e.currentTarget.requestSubmit();
}
}}
>
{children}
</form>
);
};

View File

@ -0,0 +1,39 @@
import { IMAGE_LARGE_WIDTH } from '@/site';
import Image from 'next/image';
import Link from 'next/link';
export default function ImageLarge({
className,
href,
src,
alt,
aspectRatio,
blurData,
priority,
}: {
className?: string
href: string
src: string
alt: string
aspectRatio: number
blurData: string
priority?: boolean
}) {
return (
<Link
href={href}
className="active:brightness-75"
>
<Image {...{
className,
src,
alt,
priority,
blurDataURL: blurData,
placeholder: 'blur',
width: IMAGE_LARGE_WIDTH,
height: Math.round(IMAGE_LARGE_WIDTH / aspectRatio),
}} />
</Link>
);
};

View File

@ -0,0 +1,28 @@
import { IMAGE_SMALL_WIDTH } from '@/site';
import Image from 'next/image';
export default function ImageSmall({
className,
src,
alt,
aspectRatio,
blurData,
}: {
className?: string
src: string
alt: string
aspectRatio: number
blurData: string
}) {
return (
<Image {...{
className,
src,
alt,
blurDataURL: blurData,
placeholder: 'blur',
width: IMAGE_SMALL_WIDTH,
height: Math.round(IMAGE_SMALL_WIDTH / aspectRatio),
}} />
);
};

View File

@ -0,0 +1,30 @@
import { IMAGE_TINY_WIDTH } from '@/site';
import Image from 'next/image';
export default function ImageTiny({
className,
src,
alt,
aspectRatio,
blurData,
}: {
className?: string
src: string
alt: string
aspectRatio: number
blurData?: string
}) {
return (
<Image {...{
className,
src,
alt,
...blurData && {
blurDataURL: blurData,
placeholder: 'blur',
},
width: IMAGE_TINY_WIDTH,
height: Math.round(IMAGE_TINY_WIDTH / aspectRatio),
}} />
);
};

View File

@ -0,0 +1,27 @@
import { cc } from '@/utility/css';
import { ReactNode } from 'react';
export default function InfoBlock({
children,
}: {
children: ReactNode
} ) {
return (
<div className={cc(
'flex flex-col items-center justify-center',
'px-8 py-24 rounded-lg',
'border',
'bg-gray-50 border-gray-200',
'dark:bg-gray-900/40 dark:border-gray-800',
'text-center',
)}>
<div className={cc(
'flex flex-col items-center justify-center',
'space-y-4',
'text-gray-500 dark:text-gray-400',
)}>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
'use client';
import { formatDate } from '@/utility/date';
export default function LocalDate({ date }: { date: Date }) {
return (
<>
{formatDate(date)}
</>
);
};

62
src/components/Modal.tsx Normal file
View File

@ -0,0 +1,62 @@
'use client';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { cc } from '@/utility/css';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
import { useRouter } from 'next/navigation';
import AnimateItems from './AnimateItems';
export default function Modal({
onClosePath,
children,
}: {
onClosePath?: string
children: ReactNode
}) {
const router = useRouter();
const contentRef = useRef<HTMLDivElement>(null);
const [htmlElements, setHtmlElements] = useState<HTMLDivElement[]>([]);
useEffect(() => {
if (contentRef.current) {
setHtmlElements([contentRef.current]);
}
}, []);
useClickInsideOutside({
htmlElements,
onClickOutside: () => router.push(onClosePath ?? '/'),
});
return (
<motion.div
className={cc(
'fixed inset-0 z-50 flex items-center justify-center',
'bg-black',
)}
initial={{ backgroundColor: 'rgba(0, 0, 0, 0)' }}
animate={{ backgroundColor: 'rgba(0, 0, 0, 0.80)' }}
transition={{ duration: 0.3, easing: 'easeOut' }}
>
<AnimateItems
duration={0.3}
items={[<div
key="modalContent"
className={cc(
'p-3 rounded-lg',
'bg-white dark:bg-black',
'dark:border dark:border-gray-800',
'md:p-4 md:rounded-xl',
)}
style={{ maxWidth: 'min(500px, 90vw)' }}
ref={contentRef}
>
{children}
</div>]}
/>
</motion.div>
);
};

62
src/components/Nav.tsx Normal file
View File

@ -0,0 +1,62 @@
'use client';
import { cc } from '@/utility/css';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import SiteGrid from './SiteGrid';
import { SITE_DOMAIN } from '@/site/config';
import ViewSwitcher, { SwitcherSelection } from '@/photo/ViewSwitcher';
export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
const isLoggedIn = false;
const pathname = usePathname();
const showNav = !pathname.startsWith('/sign-in');
const renderLink = (
text: string,
linkOrAction: string | (() => void),
) =>
typeof linkOrAction === 'string'
? <Link href={linkOrAction}>{text}</Link>
: <button onClick={linkOrAction}>{text}</button>;
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === '/') {
return 'full-frame';
} else if (pathname === '/grid') {
return 'grid';
} else if (pathname.startsWith('/admin')) {
return 'admin';
}
};
return (
<SiteGrid
contentMain={
<div className={cc(
'flex items-center',
'min-h-[4rem]',
'leading-none',
)}>
{showNav && <>
<div className="flex flex-grow items-center gap-4">
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={isLoggedIn}
/>
{showTextLinks && <>
{renderLink('Home', '/')}
{renderLink('Admin', '/admin')}
</>}
</div>
<div className="hidden xs:block">
{renderLink(SITE_DOMAIN, '/')}
</div>
</>}
</div>
}
/>
);
};

View File

@ -0,0 +1,36 @@
import { cc } from '@/utility/css';
export default function SiteGrid({
className,
contentMain,
contentSide,
sideFirstOnMobile,
}: {
className?: string
contentMain: JSX.Element
contentSide?: JSX.Element
sideFirstOnMobile?: boolean
}) {
return (
<div className={cc(
className,
'grid',
'grid-cols-1 md:grid-cols-12 gap-x-6 gap-y-4',
'max-w-7xl',
)}>
<div className={cc(
'col-span-1 md:col-span-9',
sideFirstOnMobile && 'order-2 md:order-none',
)}>
{contentMain}
</div>
{contentSide &&
<div className={cc(
'col-span-1 md:col-span-3',
sideFirstOnMobile && 'order-1 md:order-none'
)}>
{contentSide}
</div>}
</div>
);
};

View File

@ -0,0 +1,21 @@
import { ClipLoader } from 'react-spinners';
import resolveConfig from 'tailwindcss/resolveConfig';
import myConfig from '../../tailwind.config.js';
const TAILWIND_COLORS = resolveConfig(myConfig).theme?.colors as any;
export default function Spinner({
size = 35,
className,
}: {
size?: number
className?: string
}) {
return (
<ClipLoader {...{
size,
className,
color: TAILWIND_COLORS.gray[300],
}} />
);
};

View File

@ -0,0 +1,47 @@
'use client';
import { HTMLProps } from 'react';
import { experimental_useFormStatus as useFormStatus } from 'react-dom';
import Spinner from './Spinner';
import { cc } from '@/utility/css';
interface Props extends HTMLProps<HTMLButtonElement> {
icon?: JSX.Element
}
export default function SubmitButtonWithStatus(props: Props) {
const {
icon,
children,
disabled,
className,
type: _type,
...buttonProps
} = props;
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={disabled || pending}
className={cc(
className,
'inline-flex items-center gap-2',
)}
{...buttonProps}
>
{(icon || pending) &&
<span className={cc(
'min-w-[1rem]',
'inline-flex',
'-mx-0.5',
)}>
{pending
? <Spinner size={14} />
: icon}
</span>}
{children}
</button>
);
};

View File

@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import { cc } from '@/utility/css';
export default function Switcher({
children,
}: {
children: ReactNode
}) {
return (
<div className={cc(
'flex divide-x',
'divide-gray-300 dark:divide-gray-800',
'border rounded-[0.25rem]',
'border-gray-300 dark:border-gray-800',
'overflow-hidden',
'shadow-sm',
)}>
{children}
</div>
);
};

View File

@ -0,0 +1,39 @@
import Link from 'next/link';
import { cc } from '@/utility/css';
export default function SwitcherItem({
icon,
href,
onClick,
active,
noPadding,
}: {
icon: JSX.Element
href?: string
onClick?: () => void
active?: boolean
noPadding?: boolean
}) {
const className = cc(
'py-0.5 px-1.5',
'cursor-pointer',
'hover:bg-gray-50 active:bg-gray-100 active:text-gray-400',
// eslint-disable-next-line max-len
'dark:hover:bg-gray-950 dark:active:bg-gray-900/75 dark:active:text-gray-600',
active
? 'text-black dark:text-white'
: 'text-gray-300 dark:text-gray-700',
);
const renderIcon = () => noPadding
? icon
: <div className="w-[28px] h-[24px] flex items-center justify-center">
{icon}
</div>;
return (
href
? <Link {...{ href, className }}>{renderIcon()}</Link>
: <div {...{ onClick, className }}>{renderIcon()}</div>
);
};

View File

@ -0,0 +1,28 @@
/* eslint-disable max-len */
const INTRINSIC_WIDTH = 28;
const INTRINSIC_HEIGHT = 24;
export default function IconFullFrame({
width = INTRINSIC_WIDTH,
includeTitle = true,
}: {
width?: number
includeTitle?: boolean
}) {
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"
>
{includeTitle && <title>Full Frame</title>}
<rect x="5.625" y="6.625" width="16.75" height="10.75" rx="1" strokeWidth="1.25"/>
<line x1="5" y1="3.875" x2="23" y2="3.875" strokeWidth="1.25"/>
<line x1="23" y1="20.125" x2="5" y2="20.125" strokeWidth="1.25"/>
</svg>
);
};

29
src/icons/IconGrid.tsx Normal file
View File

@ -0,0 +1,29 @@
/* eslint-disable max-len */
const INTRINSIC_WIDTH = 28;
const INTRINSIC_HEIGHT = 24;
export default function IconGrid({
width = INTRINSIC_WIDTH,
includeTitle = true,
}: {
width?: number
includeTitle?: boolean
}) {
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"
>
{includeTitle && <title>Grid</title>}
<rect x="5.625" y="6.625" width="16.75" height="10.75" rx="1" strokeWidth="1.25"/>
<line x1="11.375" y1="7" x2="11.375" y2="18" strokeWidth="1.25"/>
<line x1="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"/>
</svg>
);
};

29
src/middleware.ts Normal file
View File

@ -0,0 +1,29 @@
import { authMiddleware, redirectToSignIn } from '@clerk/nextjs';
import { NextResponse } from 'next/server';
export default authMiddleware({
afterAuth: (auth, req) => {
if (!(
auth.isPublicRoute ||
auth.userId === process.env.CLERK_ADMIN_USER_ID
)) {
return redirectToSignIn({ returnBackUrl: req.url });
} else {
if (req.nextUrl.pathname === '/admin') {
return NextResponse.redirect(new URL('/admin/photos', req.url));
}
}
},
publicRoutes: [
'/',
'/grid',
'/photos/:photoId',
'/photos/:photoId/share',
'/photos/:photoId/image',
'/deploy-image',
],
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)'],
};

137
src/photo/PhotoForm.tsx Normal file
View File

@ -0,0 +1,137 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import {
FORM_METADATA_ENTRIES,
PhotoFormData,
} from './form';
import FieldSet from '@/components/FieldSet';
import NextImage from 'next/image';
import { createPhotoAction, updatePhotoAction } from './actions';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import Link from 'next/link';
import { cc } from '@/utility/css';
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_HEIGHT = 200;
const EDGE_BLUR_COMPENSATION = 10;
const BLUR_SCALE = 0.5;
const BLUE_JPEG_QUALITY = 0.9;
export default function PhotoForm({
initialPhotoForm,
type = 'create',
debugBlur,
}: {
initialPhotoForm: Partial<PhotoFormData>
type?: 'create' | 'edit'
debugBlur?: boolean
}) {
const [formData, setFormData] =
useState<Partial<PhotoFormData>>(initialPhotoForm);
const [showBlur, setShowBlur] = useState(debugBlur);
const canvasRef = useRef<HTMLCanvasElement>(null);
const url = formData.url ?? '';
useEffect(() => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = url;
image.onload = () => {
const canvas = canvasRef.current;
if (canvas) {
canvas.width = THUMBNAIL_WIDTH * BLUR_SCALE;
canvas.height = THUMBNAIL_HEIGHT * BLUR_SCALE;
canvas.style.width = `${THUMBNAIL_WIDTH}px`;
canvas.style.height = `${THUMBNAIL_HEIGHT}px`;
const context = canvasRef.current?.getContext('2d');
if (context) {
context.scale(BLUR_SCALE, BLUR_SCALE);
context.filter =
'contrast(1.2) saturate(1.2)' +
`blur(${BLUR_SCALE * 10}px)`;
context.drawImage(
image,
-EDGE_BLUR_COMPENSATION,
-EDGE_BLUR_COMPENSATION,
THUMBNAIL_WIDTH + EDGE_BLUR_COMPENSATION * 2,
THUMBNAIL_WIDTH * image.height / image.width
+ EDGE_BLUR_COMPENSATION * 2,
);
if (type === 'create') {
setFormData(data => ({
...data,
blurData: canvas.toDataURL('image/jpeg', BLUE_JPEG_QUALITY),
}));
}
}
}
};
}, [url, type]);
const isFormValid =
Boolean(formData.blurData) &&
Boolean(formData.title);
return (
<div className="space-y-8 max-w-[38rem]">
<NextImage
alt="Upload"
src={url}
className={cc(
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700'
)}
width={THUMBNAIL_WIDTH}
height={THUMBNAIL_HEIGHT}
/>
<canvas
ref={canvasRef}
className="hidden"
onClick={() => setShowBlur(!showBlur)}
/>
{showBlur && formData.blurData &&
<img
alt="blur"
src={formData.blurData}
width={1000}
/>}
<form
action={type === 'create' ? createPhotoAction : updatePhotoAction}
className="space-y-6 pb-12"
>
{FORM_METADATA_ENTRIES.map(([
key,
{ label, required, readOnly, hideIfEmpty },
]) =>
(!hideIfEmpty || formData[key]) &&
<FieldSet
key={key}
id={key}
label={label}
value={formData[key] ?? ''}
onChange={value => setFormData({ ...formData, [key]: value })}
required={required}
readOnly={readOnly}
/>)}
<div className="flex gap-4">
{type === 'edit' &&
<Link
className="button"
href="/admin/photos"
>
Cancel
</Link>}
<SubmitButtonWithStatus
disabled={!isFormValid}
>
{type === 'create' ? 'Create' : 'Update'}
</SubmitButtonWithStatus>
</div>
</form>
</div>
);
};

57
src/photo/PhotoGrid.tsx Normal file
View File

@ -0,0 +1,57 @@
import { Photo } from '.';
import PhotoSmall from './PhotoSmall';
import { cc } from '@/utility/css';
import AnimateItems from '@/components/AnimateItems';
import Link from 'next/link';
const PHOTOS_PER_PAGE = 6;
const PHOTOS_MAX = 35;
export default function PhotoGrid({
photos,
selectedPhoto,
offset = 0,
fast,
animateOnFirstLoadOnly,
staggerOnFirstLoadOnly,
showMore,
}: {
photos: Photo[]
selectedPhoto?: Photo
offset?: number
fast?: boolean
animate?: boolean
animateOnFirstLoadOnly?: boolean
staggerOnFirstLoadOnly?: boolean
showMore?: boolean
}) {
return (
<>
<AnimateItems
className={cc(
'grid gap-1 sm:gap-2',
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3',
'items-center',
)}
duration={fast ? 0.3 : undefined}
staggerDelay={0.075}
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
items={photos.map(photo =>
<PhotoSmall
key={photo.id}
photo={photo}
selected={photo.id === selectedPhoto?.id}
/>)}
/>
{showMore && (offset + PHOTOS_PER_PAGE) < PHOTOS_MAX &&
<Link
className="button mt-12"
href={`/grid/${offset + PHOTOS_PER_PAGE}`}
>
More
</Link>}
</>
);
};

105
src/photo/PhotoLarge.tsx Normal file
View File

@ -0,0 +1,105 @@
import { Photo } from '.';
import SiteGrid from '@/components/SiteGrid';
import ImageLarge from '@/components/ImageLarge';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { TbPhotoShare } from 'react-icons/tb';
import { routeForPhoto } from '@/site/routes';
export default function PhotoLarge({
photo,
priority,
showShare,
}: {
photo: Photo
priority?: boolean
showShare?: boolean
}) {
const renderMiniGrid = (children: JSX.Element) =>
<div className={cc(
'flex gap-y-4',
'flex-col sm:flex-row md:flex-col',
'[&>*]:sm:flex-grow',
'pr-2',
)}>
{children}
</div>;
return (
<SiteGrid
contentMain={
<ImageLarge
className="w-full"
alt={photo.title ?? 'Photo'}
href={routeForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
priority={priority}
/>}
contentSide={
<div className={cc(
'sticky top-4 self-start',
'grid grid-cols-2 md:grid-cols-1',
'gap-y-4',
'-translate-y-1',
'mb-4',
)}>
{renderMiniGrid(<>
<Link
href={routeForPhoto(photo)}
className="font-bold uppercase"
>
{photo.title}
</Link>
<div className="uppercase">
{photo.make} {photo.model}
</div>
</>)}
{renderMiniGrid(<>
<ul className={cc(
'text-gray-500',
'dark:text-gray-400',
)}>
<li>
{photo.focalLengthFormatted}
{' '}
<span className={cc(
'text-gray-400/80',
'dark:text-gray-400/50',
)}>
{photo.focalLengthIn35MmFormatFormatted}
</span>
</li>
<li>{photo.fNumberFormatted}</li>
<li>{photo.isoFormatted}</li>
<li>{photo.exposureTimeFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '—'}</li>
</ul>
<div className={cc(
'uppercase',
'text-gray-500',
'dark:text-gray-400',
)}>
{photo.takenAtNaiveFormatted}
</div>
{showShare &&
<Link
href={routeForPhoto(photo, true)}
className={cc(
'active:translate-y-[1px]',
'text-gray-500 active:text-gray-600',
'dark:text-gray-400 dark:active:text-gray-300',
)}
prefetch
>
<TbPhotoShare
className="translate-x-[-1.5px]"
size={17}
/>
</Link>}
</>)}
</div>}
/>
);
};

40
src/photo/PhotoLink.tsx Normal file
View File

@ -0,0 +1,40 @@
'use client';
import { ReactNode } from 'react';
import { Photo } from '@/photo';
import Link from 'next/link';
import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state';
import { routeForPhoto } from '@/site/routes';
export default function PhotoLink({
photo,
prefetch,
nextPhotoAnimation,
children,
}: {
photo?: Photo
prefetch?: boolean
nextPhotoAnimation?: AnimationConfig
children: ReactNode
}) {
const { setNextPhotoAnimation } = useAppState();
return (
photo
? <Link
href={routeForPhoto(photo)}
prefetch={prefetch}
onClick={() => {
if (nextPhotoAnimation) {
setNextPhotoAnimation?.(nextPhotoAnimation);
}
}}
>
{children}
</Link>
: <span className="text-gray-300 dark:text-gray-700 cursor-default">
{children}
</span>
);
};

74
src/photo/PhotoLinks.tsx Normal file
View File

@ -0,0 +1,74 @@
'use client';
import { useEffect } from 'react';
import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo';
import PhotoLink from './PhotoLink';
import { usePathname, useRouter } from 'next/navigation';
import { isRoutePhotoShare, routeForPhoto } from '@/site/routes';
import { useAppState } from '@/state';
import { AnimationConfig } from '@/components/AnimateItems';
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
export default function PhotoLinks({
photo,
photos,
}: {
photo: Photo
photos: Photo[]
}) {
const router = useRouter();
const pathname = usePathname();
const { setNextPhotoAnimation } = useAppState();
const isRouteShare = isRoutePhotoShare(pathname);
const previousPhoto = getPreviousPhoto(photo, photos);
const nextPhoto = getNextPhoto(photo, photos);
useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => {
switch (e.key.toUpperCase()) {
case 'ARROWLEFT':
case 'J':
if (previousPhoto) {
setNextPhotoAnimation?.(ANIMATION_RIGHT);
router.push(routeForPhoto(previousPhoto, isRouteShare));
}
break;
case 'ARROWRIGHT':
case 'L':
if (nextPhoto) {
setNextPhotoAnimation?.(ANIMATION_LEFT);
router.push(routeForPhoto(nextPhoto, isRouteShare));
}
break;
case 'ESCAPE':
router.push('/grid');
break;
};
};
window.addEventListener('keyup', onKeyUp);
return () => window.removeEventListener('keyup', onKeyUp);
}, [router, setNextPhotoAnimation, previousPhoto, nextPhoto, isRouteShare]);
return (
<>
<PhotoLink
photo={previousPhoto}
nextPhotoAnimation={ANIMATION_RIGHT}
prefetch
>
PREV
</PhotoLink>
<PhotoLink
photo={nextPhoto}
nextPhotoAnimation={ANIMATION_LEFT}
prefetch
>
NEXT
</PhotoLink>
</>
);
};

64
src/photo/PhotoModal.tsx Normal file
View File

@ -0,0 +1,64 @@
'use client';
import Modal from '@/components/Modal';
import PhotoOGTile from '@/photo/PhotoOGTile';
import { absoluteRouteForPhoto, routeForPhoto } from '@/site/routes';
import { TbPhotoShare } from 'react-icons/tb';
import { cc } from '@/utility/css';
import { BiCopy } from 'react-icons/bi';
import { useState } from 'react';
import { Photo } from '.';
export default function PhotoModal({ photo }: { photo: Photo }) {
const [copied, setIsCopied] = useState(false);
const shareUrl = absoluteRouteForPhoto(photo);
return (
<Modal onClosePath={routeForPhoto(photo)}>
<div className="space-y-3 md:space-y-4 w-full">
<div className={cc(
'flex items-center gap-x-3',
'text-xl md:text-3xl leading-snug',
)}>
<TbPhotoShare size={22} className="hidden xs:block" />
<div className="flex-grow">
Share Photo
</div>
{copied && <div className={cc(
'text-sm leading-none py-1.5 px-2',
'bg-blue-600 text-white',
'rounded-full',
)}>
Copied!
</div>}
</div>
<PhotoOGTile photo={photo} />
<div className={cc(
'rounded-md',
'w-full overflow-hidden',
'flex items-center justify-stretch',
'border border-gray-200 dark:border-gray-800',
)}>
<div className="truncate p-2">
{shareUrl}
</div>
<div
className={cc(
'p-3 border-l',
'border-gray-200 bg-gray-100 active:bg-gray-200',
// eslint-disable-next-line max-len
'dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
'cursor-pointer',
)}
onClick={() => {
navigator.clipboard.writeText(shareUrl);
setIsCopied(true);
}}
>
<BiCopy size={18} />
</div>
</div>
</div>
</Modal>
);
};

122
src/photo/PhotoOGTile.tsx Normal file
View File

@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { Photo, ogImageDescriptionForPhoto, ogImageUrlForPhoto } from '@/photo';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { BiError } from 'react-icons/bi';
import Spinner from '@/components/Spinner';
import { IMAGE_OG_HEIGHT, IMAGE_OG_RATIO, IMAGE_OG_WIDTH } from '@/site';
import { routeForPhoto } from '@/site/routes';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function PhotoOGTile({
photo,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
}: {
photo: Photo
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
}) {
const [loadingStateInternal, setLoadingStateInternal] =
useState(loadingStateExternal ?? 'unloaded');
const loadingState = loadingStateExternal ?? loadingStateInternal;
useEffect(() => {
if (
!loadingStateExternal &&
loadingStateInternal === 'unloaded'
) {
setLoadingStateInternal('loading');
}
}, [loadingStateExternal, loadingStateInternal]);
return (
<Link
key={photo.id}
href={routeForPhoto(photo)}
className={cc(
'block w-full rounded-md overflow-hidden',
'border shadow-sm',
'border-gray-200 dark:border-gray-800',
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
)}
>
<div
className="relative"
style={{ aspectRatio: IMAGE_OG_RATIO }}
>
{loadingState === 'loading' &&
<div className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-10',
'flex items-center justify-center',
)}>
<Spinner />
</div>}
{loadingState === 'failed' &&
<div className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-[11]',
'flex items-center justify-center',
'text-red-400',
)}>
<BiError size={32} />
</div>}
{(loadingState === 'loading' || loadingState === 'loaded') &&
<img
alt={`OG Image: ${photo.idShort}`}
className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-0',
'w-full',
loadingState === 'loading' && 'opacity-0',
'transition-opacity',
)}
src={ogImageUrlForPhoto(photo)}
width={IMAGE_OG_WIDTH}
height={IMAGE_OG_HEIGHT}
onLoad={() => {
if (onLoad) {
onLoad();
} else {
setLoadingStateInternal('loaded');
}
}}
onError={() => {
if (onFail) {
onFail();
} else {
setLoadingStateInternal('failed');
}
if (retryTime !== undefined) {
setTimeout(() => {
setLoadingStateInternal('loading');
}, retryTime);
}
}}
/>}
</div>
<div className={cc(
'md:text-lg',
'flex flex-col gap-1 p-3',
'font-sans leading-none',
'bg-gray-50 dark:bg-gray-900/50',
'border-t border-gray-200 dark:border-gray-800',
)}>
<div className="text-gray-800 dark:text-white font-medium">
{photo.title}
</div>
<div className="text-gray-500">
{ogImageDescriptionForPhoto(photo)}
</div>
</div>
</Link>
);
};

31
src/photo/PhotoSmall.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Photo } from '.';
import ImageSmall from '@/components/ImageSmall';
import Link from 'next/link';
import { cc } from '@/utility/css';
import { routeForPhoto } from '@/site/routes';
export default function PhotoSmall({
photo,
selected,
}: {
photo: Photo
selected?: boolean
}) {
return (
<Link
href={routeForPhoto(photo)}
className={cc(
'active:brightness-75',
selected && 'brightness-50',
)}
>
<ImageSmall
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
className="w-full"
alt={photo.title ?? 'Photo'}
/>
</Link>
);
};

34
src/photo/PhotoTiny.tsx Normal file
View File

@ -0,0 +1,34 @@
import { Photo } from '.';
import ImageTiny from '@/components/ImageTiny';
import Link from 'next/link';
import { cc } from '@/utility/css';
import { routeForPhoto } from '@/site/routes';
export default function PhotoTiny({
className,
photo,
selected,
}: {
className?: string
photo: Photo
selected?: boolean
}) {
return (
<Link
href={routeForPhoto(photo)}
className={cc(
className,
'active:brightness-75',
selected && 'brightness-50',
'min-w-[50px]',
)}
>
<ImageTiny
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
alt={photo.title ?? 'Photo'}
/>
</Link>
);
};

View File

@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
import Spinner from '@/components/Spinner';
import { ACCEPTED_PHOTO_FILE_TYPES, putPhoto } from '@/services/blob';
import { ROUTE_ADMIN_UPLOAD_BLOB_HANDLER } from '@/site/routes';
import { cc } from '@/utility/css';
import { useRouter } from 'next/navigation';
export default function PhotoUploadInput() {
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const router = useRouter();
return (
<div>
<div className="flex items-center gap-8">
<form className="flex items-center gap-3">
<input
type="file"
name="file"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
onChange={e => {
const file = e.target.files?.[0];
if (file) {
setIsUploading(true);
setUploadError('');
const extension = file.name.split('.').pop();
putPhoto(
file,
extension,
'upload',
ROUTE_ADMIN_UPLOAD_BLOB_HANDLER,
)
.then(({ url }) => {
// Refresh page to update upload list,
// relevant only when a photo isn't added
router.refresh();
// Redirect to photo detail page
router.push(`/admin/uploads/${encodeURIComponent(url)}`);
})
.catch(error => {
setIsUploading(false);
setUploadError(`Upload Error: ${error.message}`);
});
}
}}
disabled={isUploading}
/>
{isUploading &&
<div className={cc(
'flex items-center gap-2',
'flex-grow',
'select-none',
)}>
<Spinner size={14} />
Uploading...
</div>}
</form>
</div>
{uploadError &&
<div className="text-red-500">
{uploadError}
</div>}
</div>
);
};

View File

@ -0,0 +1,61 @@
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid';
import { SITE_CHECKLIST_STATUS } from '@/site';
import SiteChecklist from '@/site/SiteChecklist';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { HiOutlinePhotograph } from 'react-icons/hi';
export default function PhotosEmptyState() {
const showChecklist = Object.values(SITE_CHECKLIST_STATUS).some(v => !v);
return (
<SiteGrid
contentMain={
<InfoBlock>
<HiOutlinePhotograph
className="text-gray-500 dark:text-gray-400"
size={24}
/>
<div className={cc(
'font-bold text-2xl',
'text-gray-700 dark:text-gray-200',
)}>
{showChecklist
? 'Finish Setup'
: 'Welcome!'}
</div>
{showChecklist
? <SiteChecklist {...SITE_CHECKLIST_STATUS} />
: <div className="max-w-md leading-[1.7]">
<div className="mb-2">
1. Visit
{' '}
<Link
href="/admin"
className="underline hover:no-underline"
>
/admin
</Link>
{' '}
to add your first photo
</div>
<div>
2. Change the name of this blog and other configuration
by editing environment variables referenced in
{' '}
<span className={cc(
'px-1.5',
'bg-gray-100',
'border border-gray-200 dark:border-gray-700',
'dark:bg-gray-800 dark:text-gray-400',
'rounded-md',
)}>
src/site/config.ts
</span>
</div>
</div>}
</InfoBlock>}
/>
);
};

View File

@ -0,0 +1,64 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Photo } from '@/photo';
import PhotoOGTile, { OGLoadingState } from './PhotoOGTile';
const DEFAULT_MAX_CONCURRENCY = 3;
type PhotoLoadingState = Record<string, OGLoadingState>;
export default function StaggeredPhotos({
photos,
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
}: {
photos: Photo[]
maxConcurrency?: number
}) {
const [loadingState, setLoadingState] = useState(
photos.reduce((acc, photo) => ({
...acc,
[photo.id]: 'unloaded' as const,
}), {} as PhotoLoadingState),
);
const recomputeLoadingState = useCallback((
updatedState: PhotoLoadingState = {},
) => setLoadingState(currentLoadingState => {
const initialLoadingState = {
...currentLoadingState,
...updatedState,
};
const updatedLoadingState = {
...currentLoadingState,
...updatedState,
};
let imagesLoadingCount = 0;
Object.entries(initialLoadingState).forEach(([id, state]) => {
if (state === 'loading') {
imagesLoadingCount++;
} else if (imagesLoadingCount < maxConcurrency && state === 'unloaded') {
updatedLoadingState[id] = 'loading';
imagesLoadingCount++;
}
});
return updatedLoadingState;
})
, [maxConcurrency]);
useEffect(() => {
recomputeLoadingState();
}, [recomputeLoadingState]);
return photos.map(photo =>
<PhotoOGTile
key={photo.id}
photo={photo}
loadingState={loadingState[photo.id]}
onLoad={() => recomputeLoadingState({ [photo.id]: 'loaded' })}
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
riseOnHover
/>);
};

View File

@ -0,0 +1,38 @@
import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem';
import IconFullFrame from '@/icons/IconFullFrame';
import IconGrid from '@/icons/IconGrid';
import { BiLockAlt } from 'react-icons/bi';
export type SwitcherSelection = 'full-frame' | 'grid' | 'admin';
export default function ViewSwitcher({
currentSelection,
showAdmin,
}: {
currentSelection?: SwitcherSelection
showAdmin?: boolean
}) {
return (
<Switcher>
<SwitcherItem
icon={<IconFullFrame />}
href="/"
active={currentSelection === 'full-frame'}
noPadding
/>
<SwitcherItem
icon={<IconGrid />}
href="/grid"
active={currentSelection === 'grid'}
noPadding
/>
{showAdmin &&
<SwitcherItem
icon={<BiLockAlt size={15} className="-translate-y-[1px]" />}
href="/admin/photos"
active={currentSelection === 'admin'}
/>}
</Switcher>
);
}

51
src/photo/actions.ts Normal file
View File

@ -0,0 +1,51 @@
'use server';
import { revalidatePath } from 'next/cache';
import {
sqlDeletePhoto,
sqlInsertPhotoIntoDb,
sqlUpdatePhotoInDb,
} from '@/services/postgres';
import { convertFormDataToPhoto } from './form';
import { redirect } from 'next/navigation';
import {
convertUploadToPhoto,
deleteBlobPhoto,
} from '@/services/blob';
import { revalidatePhotosTag } from '@/cache';
export async function createPhotoAction(formData: FormData) {
const photo = convertFormDataToPhoto(formData);
const updatedUrl = await convertUploadToPhoto(photo.url);
if (updatedUrl) { photo.url = updatedUrl; }
await sqlInsertPhotoIntoDb(photo);
revalidatePhotosTag(true);
redirect('/admin/photos');
}
export async function updatePhotoAction(formData: FormData) {
const photo = convertFormDataToPhoto(formData);
await sqlUpdatePhotoInDb(photo);
revalidatePhotosTag(true);
redirect('/admin/photos');
}
export async function deletePhotoAction(formData: FormData) {
await deleteBlobPhoto(formData.get('url') as string);
await sqlDeletePhoto(formData.get('id') as string);
revalidatePhotosTag(true);
};
export async function deleteBlobPhotoAction(formData: FormData) {
await deleteBlobPhoto(formData.get('url') as string);
revalidatePath('/admin/photos');
};

139
src/photo/form.ts Normal file
View File

@ -0,0 +1,139 @@
import { ExifData } from 'ts-exif-parser';
import { Photo, PhotoDbInsert, PhotoExif } from '.';
import {
convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString,
} from '@/utility/date';
import { getOffsetFromExif } from '@/utility/exif';
import { toFixedNumber } from '@/utility/number';
export type PhotoFormData = Record<keyof PhotoDbInsert, string>;
type FormMeta = {
label: string,
required?: boolean,
readOnly?: boolean,
hideIfEmpty?: boolean,
hideTemporarily?: boolean,
};
const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
title: { label: 'title', required: true },
id: { label: 'id', readOnly: true, hideIfEmpty: true },
idShort: { label: 'short id', readOnly: true, hideIfEmpty: true },
url: { label: 'url', readOnly: true },
extension: { label: 'extension', readOnly: true },
aspectRatio: { label: 'aspect ratio', readOnly: true },
blurData: { label: 'blur data', readOnly: true },
make: { label: 'camera make' },
model: { label: 'camera model' },
focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
fNumber: { label: 'aperture' },
iso: { label: 'ISO' },
exposureTime: { label: 'exposure time' },
exposureCompensation: { label: 'exposure compensation' },
locationName: { label: 'location name', hideTemporarily: true },
latitude: { label: 'latitude' },
longitude: { label: 'longitude' },
filmSimulation: { label: 'film simulation', hideTemporarily: true },
priorityOrder: { label: 'priority order' },
takenAt: { label: 'taken at' },
takenAtNaive: { label: 'taken at (naive)' },
};
export const FORM_METADATA_ENTRIES =
(Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][])
.filter(([_, meta]) => !meta.hideTemporarily);
export const convertPhotoToFormData = (
photo: Photo,
): PhotoFormData => {
const valueForKey = (key: keyof Photo, value: any) => {
switch (key) {
case 'takenAt':
return value?.toISOString ? value.toISOString() : value;
default:
return value !== undefined && value !== null
? value.toString()
: undefined;
}
};
return Object.entries(photo).reduce((photoForm, [key, value]) => ({
...photoForm,
[key]: valueForKey(key as keyof Photo, value),
}), {} as PhotoFormData);
};
export const convertExifToFormData = (
data: ExifData
): Record<keyof PhotoExif, string | undefined> => ({
aspectRatio: (
(data.imageSize?.width ?? 3.0) /
(data.imageSize?.height ?? 2.0)
).toString(),
make: data.tags?.Make,
model: data.tags?.Model,
focalLength: data.tags?.FocalLength?.toString(),
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
fNumber: data.tags?.FNumber?.toString(),
iso: data.tags?.ISO?.toString(),
exposureTime: data.tags?.ExposureTime?.toString(),
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
latitude: data.tags?.GPSLatitude?.toString(),
longitude: data.tags?.GPSLongitude?.toString(),
filmSimulation: undefined,
takenAt: convertTimestampWithOffsetToPostgresString(
data.tags?.DateTimeOriginal,
getOffsetFromExif(data),
),
takenAtNaive: convertTimestampToNaivePostgresString(
data.tags?.DateTimeOriginal,
),
});
export const convertFormDataToPhoto = (
formData: FormData
): PhotoDbInsert => {
const photoForm = Object.fromEntries(formData) as PhotoFormData;
// Remove Server Action ID
Object.keys(photoForm).forEach(key => {
if (key.startsWith('$ACTION_ID_')) {
delete (photoForm as any)[key];
}
});
return {
...photoForm,
// Convert form strings to numbers
aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6),
focalLength: photoForm.focalLength
? parseInt(photoForm.focalLength)
: undefined,
focalLengthIn35MmFormat: photoForm.focalLengthIn35MmFormat
? parseInt(photoForm.focalLengthIn35MmFormat)
: undefined,
fNumber: photoForm.fNumber
? parseFloat(photoForm.fNumber)
: undefined,
latitude: photoForm.latitude
? parseFloat(photoForm.latitude)
: undefined,
longitude: photoForm.longitude
? parseFloat(photoForm.longitude)
: undefined,
iso: photoForm.iso
? parseInt(photoForm.iso)
: undefined,
exposureTime: photoForm.exposureTime
? parseFloat(photoForm.exposureTime)
: undefined,
exposureCompensation: photoForm.exposureCompensation
? parseFloat(photoForm.exposureCompensation)
: undefined,
priorityOrder: photoForm.priorityOrder
? parseFloat(photoForm.priorityOrder)
: undefined,
};
};

View File

@ -0,0 +1,92 @@
import { Photo } from '..';
import PhotoGridImageResponse from './PhotoGridImageResponse';
import IconFullFrame from '@/icons/IconFullFrame';
import IconGrid from '@/icons/IconGrid';
export default function DeployImageResponse({
photos,
request,
width,
height,
fontFamily,
outerMargin = 30,
darkMode = true,
}: {
photos: Photo[]
request: Request
width: number
height: number
fontFamily: string
outerMargin?: number
darkMode?: boolean
}) {
const innerWidth = width - (outerMargin * 2);
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
padding: outerMargin,
...darkMode
? { background: 'black', color: 'white' }
: { background: 'white', color: 'black' },
width,
height,
fontFamily,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
fontSize: 26,
height: 40,
lineHeight: 1,
marginBottom: outerMargin,
width: '100%',
}}>
<div style={{
display: 'flex',
justifyContent: 'flex-start',
flexGrow: 1,
}}>
<div style={{
display: 'flex',
border: '2px solid #333',
alignItems: 'center',
borderRadius: 8,
}}>
<div style={{
display: 'flex',
padding: '3px 10px',
color: '#333',
borderRight: '2px solid #333',
}}>
<IconFullFrame includeTitle={false} width={48} />
</div>
<div style={{
display: 'flex',
padding: '3px 10px',
}}>
<IconGrid includeTitle={false} width={48} />
</div>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
flexGrow: 1,
}}>
thephotoblog.vercel.app
</div>
</div>
<PhotoGridImageResponse {...{
photos,
request,
colCount: 4,
rowCount: 4,
width: innerWidth,
}} />
</div>
);
}

View File

@ -0,0 +1,54 @@
import { getNextImageUrlForRequest } from '@/utility/image';
import { Photo } from '..';
const IMAGE_WIDTH = 400;
export default function PhotoGridImageResponse({
photos,
request,
width,
colCount,
rowCount,
gap = 10,
}: {
photos: Photo[]
request: Request
width: number
colCount: number
rowCount: number
gap?: number
}) {
const imageWidth = (width - ((colCount - 1) * gap)) / colCount ;
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
}}>
{photos
.slice(0, colCount * rowCount)
.map((photo, index) =>
<img
key={photo.id}
src={getNextImageUrlForRequest(
photo.url,
request,
IMAGE_WIDTH,
)}
alt={photo.title}
width={IMAGE_WIDTH}
height={IMAGE_WIDTH / photo.aspectRatio}
style={{
width: imageWidth,
height: imageWidth / photo.aspectRatio,
...(index + 1) % colCount !== 0 && {
marginRight: gap,
},
...index < colCount * (rowCount - 1) && {
marginBottom: gap,
},
}}
/>)}
</div>
);
}

View File

@ -0,0 +1,75 @@
import { Photo } from '..';
import { getNextImageUrlForRequest } from '@/utility/image';
import { formatModelShort } from '@/utility/exif';
import { AiFillApple } from 'react-icons/ai';
export default function PhotoOGImageResponse({
photo,
requestOrPhotoPath,
width,
height,
fontFamily,
}: {
photo: Photo
requestOrPhotoPath: Request | string
width: number
height: number
fontFamily: string
}) {
return (
<div style={{
display: 'flex',
position: 'relative',
background: 'red',
width,
height,
}}>
<img
src={typeof requestOrPhotoPath === 'string'
? requestOrPhotoPath
: getNextImageUrlForRequest(
photo.url,
requestOrPhotoPath,
width,
)}
width={width}
height={width / photo.aspectRatio}
alt={photo.title}
/>
<div style={{
display: 'flex',
gap: 36,
position: 'absolute',
padding: '400px 56px 48px 56px',
color: 'white',
background:
'linear-gradient(to bottom, ' +
'rgba(0,0,0,0), rgba(0,0,0,0.3), rgba(0,0,0,0.7))',
backgroundBlendMode: 'multiply',
fontFamily,
fontSize: 60,
lineHeight: 1,
bottom: 0,
left: 0,
right: 0,
}}>
{photo.make === 'Apple' &&
<div style={{ display: 'flex' }}>
<AiFillApple />
</div>}
<div style={{ display: 'flex' }}>
{formatModelShort(photo.model)}
</div>
<div style={{ display: 'flex' }}>
{photo.focalLengthFormatted}
</div>
<div style={{ display: 'flex' }}>
{photo.fNumberFormatted}
</div>
<div>
{photo.isoFormatted}
</div>
</div>
</div>
);
};

142
src/photo/index.ts Normal file
View File

@ -0,0 +1,142 @@
import { BASE_URL } from '@/site/config';
import { formatDateFromPostgresString } from '@/utility/date';
import {
formatAperture,
formatIso,
formatExposureCompensation,
formatExposureTime,
formatFocalLength,
} from '@/utility/exif';
import camelcaseKeys from 'camelcase-keys';
import { Metadata } from 'next';
import short from 'short-uuid';
const translator = short();
// Core EXIF data
export interface PhotoExif {
aspectRatio: number
make?: string
model?: string
focalLength?: number
focalLengthIn35MmFormat?: number
fNumber?: number
iso?: number
exposureTime?: number
exposureCompensation?: number
latitude?: number
longitude?: number
filmSimulation?: string
takenAt: string
takenAtNaive: string
}
// Raw db insert
export interface PhotoDbInsert extends PhotoExif {
id: string
idShort: string
url: string
extension: string
blurData: string
title?: string
locationName?: string
priorityOrder?: number
}
// Raw db response
export interface PhotoDb extends Omit<PhotoDbInsert, 'takenAt'> {
updatedAt: Date
createdAt: Date
takenAt: Date
}
// Parsed db response
export interface Photo extends PhotoDb {
focalLengthFormatted?: string
focalLengthIn35MmFormatFormatted?: string
fNumberFormatted?: string
isoFormatted?: string
exposureTimeFormatted?: string
exposureCompensationFormatted?: string
takenAtNaiveFormatted?: string
}
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
const photoDb = camelcaseKeys(
photoDbRaw as unknown as Record<string, unknown>
) as unknown as PhotoDb;
return {
...photoDb,
idShort:
translator.fromUUID(photoDb.id),
focalLengthFormatted:
formatFocalLength(photoDb.focalLength),
focalLengthIn35MmFormatFormatted:
formatFocalLength(photoDb.focalLengthIn35MmFormat),
fNumberFormatted:
formatAperture(photoDb.fNumber),
isoFormatted:
formatIso(photoDb.iso),
exposureTimeFormatted:
formatExposureTime(photoDb.exposureTime),
exposureCompensationFormatted:
formatExposureCompensation(photoDb.exposureCompensation),
takenAtNaiveFormatted:
formatDateFromPostgresString(photoDb.takenAtNaive),
};
};
export const convertPhotoToPhotoDbInsert = (
photo: Photo,
): PhotoDbInsert => ({
...photo,
takenAt: photo.takenAt.toISOString(),
});
export const photoStatsAsString = (photo: Photo) => [
photo.model,
photo.focalLengthFormatted,
photo.fNumberFormatted,
photo.isoFormatted,
].join(' ');
export const ogImageUrlForPhoto = (photo: Photo) =>
`${BASE_URL}/photos/${photo.idShort}/image`;
export const ogImageDescriptionForPhoto = (photo: Photo) =>
photo.takenAtNaiveFormatted?.toUpperCase();
export const getPreviousPhoto = (photo: Photo, photos: Photo[]) => {
const index = photos.findIndex(p => p.id === photo.id);
return index > 0
? photos[index - 1]
: undefined;
};
export const getNextPhoto = (photo: Photo, photos: Photo[]) => {
const index = photos.findIndex(p => p.id === photo.id);
return index < photos.length - 1
? photos[index + 1]
: undefined;
};
export const generateImageMetaForPhoto = (photo?: Photo): Metadata => photo
? {
openGraph: {
images: ogImageUrlForPhoto(photo),
},
twitter: {
card: 'summary_large_image',
images: ogImageUrlForPhoto(photo),
},
}
: {};
const PHOTO_ID_FORWARDING_TABLE: Record<string, string> = JSON.parse(
process.env.PHOTO_ID_FORWARDING_TABLE || '{}'
);
export const translatePhotoId = (shortId: string) => {
const id = PHOTO_ID_FORWARDING_TABLE[shortId] || shortId;
return id.length === 22 ? translator.toUUID(id) : id;
};

77
src/services/blob.ts Normal file
View File

@ -0,0 +1,77 @@
import { BLOB_BASE_URL } from '@/site';
import { del, list, put } from '@vercel/blob';
export const ACCEPTED_PHOTO_FILE_TYPES = [
'image/jpg',
'image/jpeg',
'image/png',
];
const PREFIX_UPLOAD = 'upload';
const PREFIX_PHOTO = 'photo';
const REGEX_ID = new RegExp(
`\/(?:${PREFIX_UPLOAD}|${PREFIX_PHOTO})-([a-z0-9]+)\.[a-z]{1,4}`,
'i',
);
const REGEX_UPLOAD_PATH = new RegExp(
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
'i',
);
export const pathForBlobUrl = (url: string) =>
url.replace(`${BLOB_BASE_URL}/`, '');
export const getIdFromBlobUrl = (url: string) =>
url.match(REGEX_ID)?.[1];
export const getExtensionFromBlobUrl = (url: string) =>
url.match(/.([a-z]{1,4})$/i)?.[1];
export const isUploadPathnameValid = (pathname?: string) =>
pathname?.match(REGEX_UPLOAD_PATH);
export const putPhoto = async (
file: File | Blob,
extension = 'jpg',
type: 'upload' | 'photo' = 'upload',
handleBlobUploadUrl?: string,
) =>
put(
`${type === 'upload' ? PREFIX_UPLOAD : PREFIX_PHOTO}.${extension}`,
file,
{
access: 'public',
handleBlobUploadUrl,
},
);
export const convertUploadToPhoto = async (uploadUrl: string) => {
const file = await fetch(uploadUrl)
.then((response) => response.blob());
if (file) {
const { url } = await putPhoto(
file,
uploadUrl.split('.').pop(),
'photo',
);
if (url) {
await del(uploadUrl);
}
return url;
}
};
export const deleteBlobPhoto = (url: string) => del(url);
export const getBlobUploadUrls = () =>
list({ prefix: `${PREFIX_UPLOAD}-` })
.then(({ blobs }) => blobs.map(({ url }) => url));
export const getBlobPhotoUrls = () =>
list({ prefix: `${PREFIX_PHOTO}-` })
.then(({ blobs }) => blobs.map(({ url }) => url));

193
src/services/postgres.ts Normal file
View File

@ -0,0 +1,193 @@
import { sql } from '@vercel/postgres';
import {
PhotoDb,
PhotoDbInsert,
translatePhotoId,
parsePhotoFromDb,
} from '@/photo';
const PHOTO_DEFAULT_LIMIT = 100;
const sqlCreatePhotosTable = () =>
sql`
CREATE TABLE IF NOT EXISTS photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url VARCHAR(255) NOT NULL,
extension VARCHAR(255) NOT NULL,
aspect_ratio REAL DEFAULT 1.5,
blur_data TEXT,
title VARCHAR(255),
make VARCHAR(255),
model VARCHAR(255),
focal_length SMALLINT,
focal_length_in_35mm_format SMALLINT,
f_number REAL,
iso SMALLINT,
exposure_time DOUBLE PRECISION,
exposure_compensation REAL,
location_name VARCHAR(255),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
film_simulation VARCHAR(255),
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`;
export const sqlInsertPhotoIntoDb = (photo: PhotoDbInsert) => {
return sql`
INSERT INTO photos (
url,
extension,
aspect_ratio,
blur_data,
title,
make,
model,
focal_length,
focal_length_in_35mm_format,
f_number,
iso,
exposure_time,
exposure_compensation,
location_name,
latitude,
longitude,
film_simulation,
priority_order,
taken_at,
taken_at_naive
)
VALUES (
${photo.url},
${photo.extension},
${photo.aspectRatio},
${photo.blurData},
${photo.title},
${photo.make},
${photo.model},
${photo.focalLength},
${photo.focalLengthIn35MmFormat},
${photo.fNumber},
${photo.iso},
${photo.exposureTime},
${photo.exposureCompensation},
${photo.locationName},
${photo.latitude},
${photo.longitude},
${photo.filmSimulation},
${photo.priorityOrder},
${photo.takenAt},
${photo.takenAtNaive}
)
`;
};
export const sqlUpdatePhotoInDb = (photo: PhotoDbInsert) =>
sql`
UPDATE photos SET
url=${photo.url},
extension=${photo.extension},
aspect_ratio=${photo.aspectRatio},
blur_data=${photo.blurData},
title=${photo.title},
make=${photo.make},
model=${photo.model},
focal_length=${photo.focalLength},
focal_length_in_35mm_format=${photo.focalLengthIn35MmFormat},
f_number=${photo.fNumber},
iso=${photo.iso},
exposure_time=${photo.exposureTime},
exposure_compensation=${photo.exposureCompensation},
location_name=${photo.locationName},
latitude=${photo.latitude},
longitude=${photo.longitude},
film_simulation=${photo.filmSimulation},
priority_order=${photo.priorityOrder},
taken_at=${photo.takenAt},
taken_at_naive=${photo.takenAtNaive},
updated_at=${(new Date()).toISOString()}
WHERE id=${photo.id}
`;
export const sqlDeletePhoto = (id: string) =>
sql`DELETE FROM photos WHERE id=${id}`;
const sqlGetPhotosFromDb = (
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
) =>
sql<PhotoDb>`
SELECT * FROM photos
ORDER BY taken_at DESC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
const sqlGetPhotosFromDbSortedByCreatedAt = (
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
) =>
sql<PhotoDb>`
SELECT * FROM photos
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
const sqlGetPhotosFromDbSortedByPriority = (
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
) =>
sql<PhotoDb>`
SELECT * FROM photos
ORDER BY priority_order ASC
LIMIT ${limit} OFFSET ${offset}
`
.then(({ rows }) => rows.map(parsePhotoFromDb));
const sqlGetPhotoFromDb = (id: string) =>
sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
.then(({ rows }) => rows.map(parsePhotoFromDb));
export const getPhotos = async (
sortBy: 'createdAt' | 'takenAt' | 'priority' = 'takenAt',
limit?: number,
offset?: number,
) => {
let photos;
const getPhotosRequest = sortBy === 'createdAt'
? sqlGetPhotosFromDbSortedByCreatedAt
: sortBy === 'priority'
? sqlGetPhotosFromDbSortedByPriority
: sqlGetPhotosFromDb;
try {
photos = await getPhotosRequest(limit, offset);
} catch (e: any) {
if (e.message === 'relation "photos" does not exist') {
console.log(
'Creating table "photos" because it did not exist',
);
await sqlCreatePhotosTable();
photos = await getPhotosRequest(limit, offset);
} else {
console.log(`sql get error: ${e.message} `);
throw e;
}
}
return photos;
};
export const getPhoto = (id: string) =>
sqlGetPhotoFromDb(
// Check for photo id forwarding
// and convert short ids to uuids
translatePhotoId(id)
)
.then(photos => photos[0]);

144
src/site/SiteChecklist.tsx Normal file
View File

@ -0,0 +1,144 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { cc } from '@/utility/css';
import SiteChecklistRow from './SiteChecklistRow';
import { FiExternalLink } from 'react-icons/fi';
export default function SiteChecklist({
hasTitle,
hasDomain,
hasPostgres,
hasBlob,
hasAuth,
showRefreshButton,
}: {
hasTitle: boolean
hasDomain: boolean
hasPostgres: boolean
hasBlob: boolean
hasAuth: boolean
showRefreshButton?: boolean
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const refreshSetupStatus = () => {
startTransition(router.refresh);
};
const renderLink = (href: string, text: string, external = true) =>
<>
<a {...{
href,
...external && { target: '_blank', rel: 'noopener noreferrer' },
className: cc(
'underline hover:no-underline',
),
}}>
{text}
</a>
{external &&
<>
&nbsp;
<FiExternalLink
size={14}
className='inline translate-y-[-1.5px]'
/>
</>}
</>;
const renderEnvVar = (variables: string[]) =>
<div className="py-1 space-y-1">
{variables.map(variable =>
<div key={variable}>
<span className={cc(
'rounded-sm',
'bg-gray-100 text-gray-500',
'dark:bg-gray-800 dark:text-gray-400',
)}>
`{variable}`
</span>
</div>)}
</div>;
return (
<div className={cc(
'text-sm',
'max-w-xl',
'bg-white dark:bg-black',
'dark:text-gray-400',
'border border-gray-200 dark:border-gray-800 rounded-md',
'divide-y divide-gray-200 dark:divide-gray-800',
)}>
<SiteChecklistRow
title="Add title"
status={hasTitle}
isPending={isPending}
>
Store in environment variable:
{renderEnvVar(['NEXT_PUBLIC_SITE_TITLE'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Add domain"
status={hasDomain}
isPending={isPending}
>
Store in environment variable:
{renderEnvVar(['NEXT_PUBLIC_SITE_DOMAIN'])}
</SiteChecklistRow>
<SiteChecklistRow
title="Setup database"
status={hasPostgres}
isPending={isPending}
>
{renderLink(
'https://vercel.com/docs/storage/vercel-postgres/quickstart',
'Create Vercel Postgres store',
)}
{' '}
and connect to project
</SiteChecklistRow>
<SiteChecklistRow
title="Setup blob store"
status={hasBlob}
isPending={isPending}
>
{renderLink(
'https://vercel.com/docs/storage/vercel-blob/quickstart',
'Create Vercel Blob store',
)}
{' '}
and connect to project
</SiteChecklistRow>
<SiteChecklistRow
title="Setup auth"
status={hasAuth}
isPending={isPending}
>
{renderLink(
'https://clerk.com/docs/quickstarts/setup-clerk',
'Create Clerk account',
)}
{' '}
and add environment variables:
{renderEnvVar([
'NEXT_PUBLIC_CLERK_SIGN_IN_URL',
'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
'CLERK_SECRET_KEY',
'CLERK_ADMIN_USER_ID',
])}
</SiteChecklistRow>
<div className="py-4 space-y-4">
<div className="px-8 text-gray-400">
Changes to environment variables require a redeploy
or reboot of local dev server
</div>
{showRefreshButton &&
<button onClick={refreshSetupStatus}>
Check
</button>}
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { ReactNode } from 'react';
import { cc } from '@/utility/css';
import Spinner from '@/components/Spinner';
export default function SiteChecklistRow({
title,
status,
isPending,
children,
}: {
title: string
status: boolean
isPending: boolean
children: ReactNode
}) {
return (
<div className={cc(
'flex gap-2.5',
'px-4 py-3',
'text-left',
)}>
<div className="min-w-[1rem] pt-[1px]">
{isPending
? <div className="translate-y-0.5">
<Spinner size={14} />
</div>
: <div className="text-[0.8rem]">
{status ? '✅' : '❌'}
</div>}
</div>
<div className="flex flex-col items-start">
<div className="font-bold dark:text-gray-300">{title}</div>
<div>{children}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import { ThemeProvider } from 'next-themes';
export default function ThemeProviderClient({
children,
}: {
children: React.ReactNode
}) {
return (
<ThemeProvider attribute="class">
{children}
</ThemeProvider>
);
}

View File

@ -0,0 +1,41 @@
'use client';
import { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
export default function ThemeSwitcher () {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<Switcher>
<SwitcherItem
icon={<BiDesktop size={16} />}
onClick={() => setTheme('system')}
active={theme === 'system'}
/>
<SwitcherItem
icon={<BiSun size={18} />}
onClick={() => setTheme('light')}
active={theme === 'light'}
/>
<SwitcherItem
icon={<BiMoon size={16} />}
onClick={() => setTheme('dark')}
active={theme === 'dark'}
/>
</Switcher>
);
}

13
src/site/config.ts Normal file
View File

@ -0,0 +1,13 @@
export const SITE_TITLE = process.env.NEXT_PUBLIC_SITE_TITLE
|| 'Photo Blog';
export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN
|| process.env.NEXT_PUBLIC_VERCEL_URL
|| SITE_TITLE;
export const SITE_DESCRIPTION = process.env.NEXT_PUBLIC_SITE_DESCRIPTION
|| SITE_DOMAIN;
export const BASE_URL = process.env.NODE_ENV === 'production'
? `https://${SITE_DOMAIN}`
: 'http://localhost:3000';

7
src/site/font.ts Normal file
View File

@ -0,0 +1,7 @@
export const FONT_FAMILY_IBM_PLEX_MONO = 'IBMPlexMono';
export const getIBMPlexMonoMedium = () => fetch(new URL(
'/public/fonts/IBMPlexMono-Medium.ttf',
import.meta.url
))
.then(res => res.arrayBuffer());

61
src/site/globals.css Normal file
View File

@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply
font-mono text-sm md:text-base
bg-white dark:bg-black
text-gray-900 dark:text-gray-100
}
label {
@apply
font-sans font-medium block uppercase text-xs
text-gray-500 dark:text-gray-400
tracking-wider
}
button, .button,
input[type=text] {
@apply
px-2 py-1.5
border rounded-md
dark:bg-black
border-gray-200 dark:border-gray-700
font-mono text-base leading-none
min-h-[2.25rem]
}
input[type=text] {
@apply
min-w-[20rem] read-only:cursor-default
read-only:bg-gray-100
dark:read-only:bg-gray-900 dark:read-only:text-gray-400
}
input[type=file] {
@apply
block font-mono w-full text-gray-500 dark:text-gray-400
file:bg-white dark:file:bg-gray-950
file:mr-2 file:my-2 file:px-4 file:py-1.5 file:rounded-md
file:border-solid file:border
file:border-gray-200 dark:file:border-gray-700
file:cursor-pointer
file:shadow-sm
file:active:bg-gray-100
file:disabled:bg-gray-100
file:hover:border-gray-300
file:hover:disabled:border-gray-200
file:active:disabled:bg-white
file:hover:disabled:cursor-not-allowed
}
button, .button {
@apply
inline-flex gap-2 items-center
px-4
text-base
shadow-sm
disabled:bg-gray-100 dark:disabled:bg-gray-900 disabled:cursor-not-allowed
active:bg-gray-100 dark:active:bg-gray-900
hover:border-gray-300 dark:hover:border-gray-600
hover:disabled:border-gray-200
}
}

38
src/site/index.ts Normal file
View File

@ -0,0 +1,38 @@
// Height determined by intrinsic photo aspect ratio
export const IMAGE_TINY_WIDTH = 50;
// Height determined by intrinsic photo aspect ratio
export const IMAGE_SMALL_WIDTH = 300;
// Height determined by intrinsic photo aspect ratio
export const IMAGE_LARGE_WIDTH = 900;
// 16:9 og image ratio
export const IMAGE_OG_RATIO = 16 / 9;
export const IMAGE_OG_WIDTH = 1200;
export const IMAGE_OG_HEIGHT = IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO);
// 3:2 og grid ratio
export const GRID_OG_RATIO = 1.35;
export const GRID_OG_WIDTH = 1200;
export const GRID_OG_HEIGHT = GRID_OG_WIDTH * (1 / GRID_OG_RATIO);
const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
)?.[1].toLowerCase();
export const BLOB_BASE_URL =
`https://${STORE_ID}.public.blob.vercel-storage.com`;
export const SITE_CHECKLIST_STATUS = {
hasTitle: (process.env.NEXT_PUBLIC_SITE_TITLE ?? '').length > 0,
hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0,
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
hasBlob: (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0,
hasAuth: (
(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ?? '').length > 0 &&
(process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL ?? '').length > 0 &&
(process.env.CLERK_SECRET_KEY ?? '').length > 0 &&
(process.env.CLERK_ADMIN_USER_ID ?? '').length > 0
),
};

23
src/site/routes.ts Normal file
View File

@ -0,0 +1,23 @@
import { Photo } from '@/photo';
import { SITE_DOMAIN } from './config';
export const ROUTE_ADMIN_UPLOAD = '/admin/uploads';
export const ROUTE_ADMIN_UPLOAD_BLOB_HANDLER = '/admin/uploads/blob';
export const routeForPhoto = (photo: Photo, share?: boolean) =>
share
? `/photos/${photo.idShort}/share`
: `/photos/${photo.idShort}`;
export const absoluteRouteForPhoto = (photo: Photo) =>
`https://${SITE_DOMAIN}${routeForPhoto(photo)}`;
export const isRoutePhoto = (pathname = '') =>
/^\/photos\/[^/]+\/?$/.test(pathname);
export const isRoutePhotoShare = (pathname = '') =>
/^\/photos\/[^/]+\/share\/?$/.test(pathname);
export const isRouteSignIn = (pathname = '') =>
pathname.startsWith('/sign-in');

View File

@ -0,0 +1,38 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
import { AppStateContext } from '.';
import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames';
export default function StateProvider({
children,
}: {
children: ReactNode
}) {
const { previousPathname } = usePathnames();
const [hasLoaded, setHasLoaded] = useState(false);
const [nextPhotoAnimation, setNextPhotoAnimation] =
useState<AnimationConfig>();
useEffect(() => {
setHasLoaded?.(true);
}, [setHasLoaded]);
return (
<AppStateContext.Provider
value={{
previousPathname,
hasLoaded,
setHasLoaded,
nextPhotoAnimation,
setNextPhotoAnimation,
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
}}
>
{children}
</AppStateContext.Provider>
);
};

15
src/state/index.ts Normal file
View File

@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
import { AnimationConfig } from '@/components/AnimateItems';
export interface AppStateContext {
previousPathname?: string
hasLoaded?: boolean
setHasLoaded?: (hasLoaded: boolean) => void
nextPhotoAnimation?: AnimationConfig
setNextPhotoAnimation?: (animation?: AnimationConfig) => void
clearNextPhotoAnimation?: () => void
}
export const AppStateContext = createContext<AppStateContext>({});
export const useAppState = () => useContext(AppStateContext);

6
src/utility/css.ts Normal file
View File

@ -0,0 +1,6 @@
export const cc = (
...classes: (string | boolean | undefined)[]
): string =>
classes
.filter(s => typeof s === 'string' && s.length)
.join(' ');

Some files were not shown because too many files have changed in this diff Show More