Init
This commit is contained in:
commit
df11a86181
38
.eslintrc.json
Normal file
38
.eslintrc.json
Normal 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
38
.gitignore
vendored
Normal 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
21
.vscode/settings.json
vendored
Normal 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
40
README.md
Normal 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._
|
||||
|
||||
[](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
22
next.config.js
Normal 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
40
package.json
Normal 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
4041
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicons/apple-touch-icon.png
Normal file
BIN
public/favicons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicons/dark.png
Normal file
BIN
public/favicons/dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 B |
BIN
public/favicons/light.png
Normal file
BIN
public/favicons/light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 B |
BIN
public/fonts/IBMPlexMono-Bold.ttf
Normal file
BIN
public/fonts/IBMPlexMono-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-BoldItalic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-ExtraLight.ttf
Normal file
BIN
public/fonts/IBMPlexMono-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-ExtraLightItalic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-Italic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-Italic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-Light.ttf
Normal file
BIN
public/fonts/IBMPlexMono-Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-LightItalic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-LightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-Medium.ttf
Normal file
BIN
public/fonts/IBMPlexMono-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-MediumItalic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-Regular.ttf
Normal file
BIN
public/fonts/IBMPlexMono-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-SemiBold.ttf
Normal file
BIN
public/fonts/IBMPlexMono-SemiBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-SemiBoldItalic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-Thin.ttf
Normal file
BIN
public/fonts/IBMPlexMono-Thin.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IBMPlexMono-ThinItalic.ttf
Normal file
BIN
public/fonts/IBMPlexMono-ThinItalic.ttf
Normal file
Binary file not shown.
93
public/fonts/OFL.txt
Normal file
93
public/fonts/OFL.txt
Normal 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.
|
||||
23
src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx
Normal file
23
src/app/(auth-state)/admin/photos/[photoId]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
185
src/app/(auth-state)/admin/photos/page.tsx
Normal file
185
src/app/(auth-state)/admin/photos/page.tsx
Normal 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>;
|
||||
}
|
||||
42
src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx
Normal file
42
src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/app/(auth-state)/admin/uploads/blob/route.tsx
Normal file
39
src/app/(auth-state)/admin/uploads/blob/route.tsx
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
14
src/app/(auth-state)/checklist/page.tsx
Normal file
14
src/app/(auth-state)/checklist/page.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/app/(auth-state)/layout.tsx
Normal file
17
src/app/(auth-state)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(auth-state)/sign-in/[[...sign-in]]/page.tsx
Normal file
16
src/app/(auth-state)/sign-in/[[...sign-in]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/app/(isr)/deploy-image/route.tsx
Normal file
42
src/app/(isr)/deploy-image/route.tsx
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
34
src/app/(isr)/deploy-url/route.tsx
Normal file
34
src/app/(isr)/deploy-url/route.tsx
Normal 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());
|
||||
}
|
||||
29
src/app/(isr)/grid/[offset]/page.tsx
Normal file
29
src/app/(isr)/grid/[offset]/page.tsx
Normal 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
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
src/app/(isr)/grid/page.tsx
Normal file
29
src/app/(isr)/grid/page.tsx
Normal 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
33
src/app/(isr)/layout.tsx
Normal 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
14
src/app/(isr)/og/page.tsx
Normal 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
36
src/app/(isr)/page.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
42
src/app/(isr)/photos/[photoId]/image/route.tsx
Normal file
42
src/app/(isr)/photos/[photoId]/image/route.tsx
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
81
src/app/(isr)/photos/[photoId]/layout.tsx
Normal file
81
src/app/(isr)/photos/[photoId]/layout.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
3
src/app/(isr)/photos/[photoId]/page.tsx
Normal file
3
src/app/(isr)/photos/[photoId]/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
13
src/app/(isr)/photos/[photoId]/share/page.tsx
Normal file
13
src/app/(isr)/photos/[photoId]/share/page.tsx
Normal 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
83
src/app/layout.tsx
Normal 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
42
src/cache/index.ts
vendored
Normal 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)],
|
||||
}
|
||||
)();
|
||||
23
src/components/AdminChildPage.tsx
Normal file
23
src/components/AdminChildPage.tsx
Normal 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;
|
||||
120
src/components/AnimateItems.tsx
Normal file
120
src/components/AnimateItems.tsx
Normal 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;
|
||||
55
src/components/AuthNav.tsx
Normal file
55
src/components/AuthNav.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
40
src/components/FieldSet.tsx
Normal file
40
src/components/FieldSet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
src/components/FormWithConfirm.tsx
Normal file
28
src/components/FormWithConfirm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/ImageLarge.tsx
Normal file
39
src/components/ImageLarge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
src/components/ImageSmall.tsx
Normal file
28
src/components/ImageSmall.tsx
Normal 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),
|
||||
}} />
|
||||
);
|
||||
};
|
||||
30
src/components/ImageTiny.tsx
Normal file
30
src/components/ImageTiny.tsx
Normal 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),
|
||||
}} />
|
||||
);
|
||||
};
|
||||
27
src/components/InfoBlock.tsx
Normal file
27
src/components/InfoBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/LocalDate.tsx
Normal file
11
src/components/LocalDate.tsx
Normal 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
62
src/components/Modal.tsx
Normal 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
62
src/components/Nav.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
36
src/components/SiteGrid.tsx
Normal file
36
src/components/SiteGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
src/components/Spinner.tsx
Normal file
21
src/components/Spinner.tsx
Normal 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],
|
||||
}} />
|
||||
);
|
||||
};
|
||||
47
src/components/SubmitButtonWithStatus.tsx
Normal file
47
src/components/SubmitButtonWithStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
src/components/Switcher.tsx
Normal file
21
src/components/Switcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/SwitcherItem.tsx
Normal file
39
src/components/SwitcherItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
src/icons/IconFullFrame.tsx
Normal file
28
src/icons/IconFullFrame.tsx
Normal 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
29
src/icons/IconGrid.tsx
Normal 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
29
src/middleware.ts
Normal 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
137
src/photo/PhotoForm.tsx
Normal 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
57
src/photo/PhotoGrid.tsx
Normal 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
105
src/photo/PhotoLarge.tsx
Normal 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
40
src/photo/PhotoLink.tsx
Normal 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
74
src/photo/PhotoLinks.tsx
Normal 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
64
src/photo/PhotoModal.tsx
Normal 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
122
src/photo/PhotoOGTile.tsx
Normal 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
31
src/photo/PhotoSmall.tsx
Normal 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
34
src/photo/PhotoTiny.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
src/photo/PhotoUploadInput.tsx
Normal file
69
src/photo/PhotoUploadInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
src/photo/PhotosEmptyState.tsx
Normal file
61
src/photo/PhotosEmptyState.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
64
src/photo/StaggeredPhotos.tsx
Normal file
64
src/photo/StaggeredPhotos.tsx
Normal 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
|
||||
/>);
|
||||
};
|
||||
38
src/photo/ViewSwitcher.tsx
Normal file
38
src/photo/ViewSwitcher.tsx
Normal 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
51
src/photo/actions.ts
Normal 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
139
src/photo/form.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
92
src/photo/image-response/DeployImageResponse.tsx
Normal file
92
src/photo/image-response/DeployImageResponse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/photo/image-response/PhotoGridImageResponse.tsx
Normal file
54
src/photo/image-response/PhotoGridImageResponse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/photo/image-response/PhotoOGImageResponse.tsx
Normal file
75
src/photo/image-response/PhotoOGImageResponse.tsx
Normal 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
142
src/photo/index.ts
Normal 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
77
src/services/blob.ts
Normal 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
193
src/services/postgres.ts
Normal 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
144
src/site/SiteChecklist.tsx
Normal 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 &&
|
||||
<>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
37
src/site/SiteChecklistRow.tsx
Normal file
37
src/site/SiteChecklistRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/site/ThemeProviderClient.tsx
Normal file
15
src/site/ThemeProviderClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/site/ThemeSwitcher.tsx
Normal file
41
src/site/ThemeSwitcher.tsx
Normal 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
13
src/site/config.ts
Normal 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
7
src/site/font.ts
Normal 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
61
src/site/globals.css
Normal 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
38
src/site/index.ts
Normal 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
23
src/site/routes.ts
Normal 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');
|
||||
38
src/state/AppStateProvider.tsx
Normal file
38
src/state/AppStateProvider.tsx
Normal 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
15
src/state/index.ts
Normal 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
6
src/utility/css.ts
Normal 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
Loading…
Reference in New Issue
Block a user