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