Merge branch 'main' into refresh-exif

This commit is contained in:
Sam Becker 2023-10-31 18:52:10 -05:00
commit bf78ced898
23 changed files with 378 additions and 323 deletions

View File

@ -19,6 +19,7 @@
"qaub", "qaub",
"QRSTUVWXYZ", "QRSTUVWXYZ",
"Reala", "Reala",
"CredentialsSignin",
"skippable", "skippable",
"sonner", "sonner",
"thephotoblog", "thephotoblog",

View File

@ -50,8 +50,9 @@ Installation
### 4. Develop locally ### 4. Develop locally
1. Clone code 1. Clone code
2. Install dependencies `pnpm i` 2. Run `pnpm i` to install dependencies
3. Run `vc dev` to utilize Vercel-stored environment variables 3. Set environment variable `AUTH_URL` locally (not in production) to `http://localhost:3000/api/url` (_this is a temporary limitation of `next-auth` v5.0_)
4. Run `vc dev` to start dev server, and utilize Vercel-stored environment variables
### 5. Add Analytics (optional) ### 5. Add Analytics (optional)
@ -68,8 +69,8 @@ Installation
FAQ FAQ
- -
Q: My images/content have fallen out of sync with my database and/or production site no longer matches local development. What do I do?<br /> Q: My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?<br />
A: Navigate to `/admin/configuration` and click the "Clear Cache" button. A: Navigate to `/admin/configuration` and click "Clear Cache" button.
Q: I'm seeing server-side runtime errors when loading a page after updating my fork. What do I do?<br /> Q: I'm seeing server-side runtime errors when loading a page after updating my fork. What do I do?<br />
A: Navigate to `/admin/configuration` and click the "Clear Cache" button. If this doesn't help, [open an issue](https://github.com/sambecker/exif-photo-blog/issues/new). A: Navigate to `/admin/configuration` and click "Clear Cache" button. If this doesn't help, [open an issue](https://github.com/sambecker/exif-photo-blog/issues/new).

View File

@ -19,9 +19,6 @@ const nextConfig = {
}], }],
minimumCacheTTL: 31536000, minimumCacheTTL: 31536000,
}, },
experimental: {
serverActions: true,
},
}; };
module.exports = withBundleAnalyzer(nextConfig); module.exports = withBundleAnalyzer(nextConfig);

View File

@ -9,37 +9,37 @@
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {
"@next/bundle-analyzer": "^13.5.6", "@next/bundle-analyzer": "^14.0.1",
"@tailwindcss/forms": "^0.5.6", "@tailwindcss/forms": "^0.5.6",
"@testing-library/jest-dom": "^6.1.4", "@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.6", "@types/jest": "^29.5.7",
"@types/node": "^20.8.7", "@types/node": "^20.8.9",
"@types/react": "18.2.29", "@types/react": "18.2.33",
"@types/react-dom": "18.2.8", "@types/react-dom": "18.2.14",
"@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.8.0", "@typescript-eslint/parser": "^6.9.1",
"@vercel/analytics": "^1.1.1", "@vercel/analytics": "^1.1.1",
"@vercel/blob": "^0.14.0", "@vercel/blob": "^0.14.1",
"@vercel/postgres": "0.5.0", "@vercel/postgres": "0.5.0",
"autoprefixer": "10.4.16", "autoprefixer": "10.4.16",
"camelcase-keys": "^9.1.2", "camelcase-keys": "^9.1.2",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"eslint": "8.51.0", "eslint": "8.52.0",
"eslint-config-next": "13.5.6", "eslint-config-next": "14.0.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.2", "nanoid": "^5.0.2",
"next": "^13.5.6", "next": "^14.0.1",
"next-auth": "0.0.0-manual.c885ac1d", "next-auth": "5.0.0-beta.3",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "8.4.31", "postcss": "8.4.31",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"sonner": "^1.0.3", "sonner": "^1.0.3",
"tailwindcss": "3.3.3", "tailwindcss": "3.3.5",
"ts-exif-parser": "^0.2.2", "ts-exif-parser": "^0.2.2",
"typescript": "5.2.2" "typescript": "5.2.2"
} }

423
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,10 @@
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { FaTimes } from 'react-icons/fa';
export default function DeleteButton () { export default function DeleteButton () {
return <SubmitButtonWithStatus return <SubmitButtonWithStatus
title="Delete" title="Delete"
icon={<span className="inline-flex text-[18px]">×</span>} icon={<FaTimes size={13} className="translate-y-[1px]" />}
> >
Delete Delete
</SubmitButtonWithStatus>; </SubmitButtonWithStatus>;

View File

@ -6,7 +6,7 @@ import {
} from '@/photo/image-response'; } from '@/photo/image-response';
import HomeImageResponse from '@/photo/image-response/HomeImageResponse'; import HomeImageResponse from '@/photo/image-response/HomeImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font'; import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server'; import { ImageResponse } from 'next/og';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -3,7 +3,7 @@ import { getImageCacheHeadersForAuth, getPhotoCached } from '@/cache';
import { IMAGE_OG_SIZE } from '@/photo/image-response'; import { IMAGE_OG_SIZE } from '@/photo/image-response';
import PhotoImageResponse from '@/photo/image-response/PhotoImageResponse'; import PhotoImageResponse from '@/photo/image-response/PhotoImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font'; import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server'; import { ImageResponse } from 'next/og';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -7,7 +7,7 @@ import {
} from '@/photo/image-response'; } from '@/photo/image-response';
import CameraImageResponse from '@/photo/image-response/CameraImageResponse'; import CameraImageResponse from '@/photo/image-response/CameraImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font'; import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server'; import { ImageResponse } from 'next/og';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -6,7 +6,7 @@ import {
} from '@/photo/image-response'; } from '@/photo/image-response';
import TagImageResponse from '@/photo/image-response/TagImageResponse'; import TagImageResponse from '@/photo/image-response/TagImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font'; import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server'; import { ImageResponse } from 'next/og';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -7,7 +7,7 @@ import {
import TemplateImageResponse from import TemplateImageResponse from
'@/photo/image-response/TemplateImageResponse'; '@/photo/image-response/TemplateImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font'; import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server'; import { ImageResponse } from 'next/og';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -7,7 +7,7 @@ import {
import TemplateImageResponse from import TemplateImageResponse from
'@/photo/image-response/TemplateImageResponse'; '@/photo/image-response/TemplateImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font'; import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/server'; import { ImageResponse } from 'next/og';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -3,60 +3,55 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import InfoBlock from '@/components/InfoBlock'; import InfoBlock from '@/components/InfoBlock';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { signIn } from 'next-auth/react';
import { useLayoutEffect, useRef, useState } from 'react'; import { useLayoutEffect, useRef, useState } from 'react';
import { signInAction } from './action';
import { useFormState } from 'react-dom';
import ErrorNote from '@/components/ErrorNote';
import { CREDENTIALS_SIGN_IN_ERROR } from '.';
export default function SignInForm() { export default function SignInForm() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false); const [response, action] = useFormState(signInAction, undefined);
const emailRef = useRef<HTMLInputElement>(null); const emailRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
emailRef.current?.focus(); emailRef.current?.focus();
}, []); }, []);
const isFormValid =
email.length > 0 &&
password.length > 0;
return ( return (
<InfoBlock> <InfoBlock>
<form <form action={action}>
className="space-y-8" <div className="space-y-8">
onSubmitCapture={e => { {response === CREDENTIALS_SIGN_IN_ERROR &&
e.preventDefault(); <ErrorNote>
setIsSigningIn(true); Invalid email/password
signIn( </ErrorNote>}
'credentials', <div className="space-y-4">
{ <FieldSetWithStatus
email, id="email"
password, inputRef={emailRef}
callbackUrl: PATH_ADMIN_PHOTOS, label="Admin Email"
}, type="email"
) value={email}
.catch(() => setIsSigningIn(false)); onChange={setEmail}
}} />
> <FieldSetWithStatus
<div className="space-y-4"> id="password"
<FieldSetWithStatus label="Admin Password"
id="email" type="password"
inputRef={emailRef} value={password}
label="Admin Email" onChange={setPassword}
type="email" />
value={email} </div>
onChange={setEmail} <SubmitButtonWithStatus disabled={!isFormValid}>
readOnly={isSigningIn} Sign in
/> </SubmitButtonWithStatus>
<FieldSetWithStatus
id="password"
label="Admin Password"
type="password"
value={password}
onChange={setPassword}
readOnly={isSigningIn}
/>
</div> </div>
<SubmitButtonWithStatus disabled={isSigningIn}>
Sign in
</SubmitButtonWithStatus>
</form> </form>
</InfoBlock> </InfoBlock>
); );

21
src/auth/action.ts Normal file
View File

@ -0,0 +1,21 @@
'use server';
import { CREDENTIALS_SIGN_IN_ERROR, signIn, signOut } from '@/auth';
export const signInAction = async (
_prevState: string | undefined,
formData: FormData,
) => {
try {
await signIn('credentials', Object.fromEntries(formData));
} catch (error) {
if ((error as Error).message.includes(CREDENTIALS_SIGN_IN_ERROR)) {
return CREDENTIALS_SIGN_IN_ERROR;
}
throw error;
}
};
export const signOutAction = async () => {
await signOut();
};

View File

@ -1,25 +1,17 @@
import { isPathProtected } from '@/site/paths'; import { isPathProtected } from '@/site/paths';
import NextAuth, { User, type DefaultSession } from 'next-auth'; import NextAuth, { User } from 'next-auth';
import Credentials from 'next-auth/providers/credentials'; import Credentials from 'next-auth/providers/credentials';
declare module 'next-auth' { export const CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin';
interface Session {
user: {
id: string
} & DefaultSession['user']
}
}
export const { export const {
handlers: { GET, POST }, handlers: { GET, POST },
signIn,
signOut,
auth, auth,
} = NextAuth({ } = NextAuth({
providers: [ providers: [
Credentials({ Credentials({
credentials: {
email: { label: 'Email', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize({ email, password }) { async authorize({ email, password }) {
if ( if (
process.env.ADMIN_EMAIL && process.env.ADMIN_EMAIL === email && process.env.ADMIN_EMAIL && process.env.ADMIN_EMAIL === email &&

4
src/cache/index.ts vendored
View File

@ -15,7 +15,7 @@ import {
} from '@/services/postgres'; } from '@/services/postgres';
import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo'; import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo';
import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob'; import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob';
import { AuthSession } from 'next-auth'; import type { Session } from 'next-auth';
import { Camera, createCameraKey } from '@/camera'; import { Camera, createCameraKey } from '@/camera';
import { PATHS_ADMIN, PATHS_TO_CACHE } from '@/site/paths'; import { PATHS_ADMIN, PATHS_TO_CACHE } from '@/site/paths';
@ -226,7 +226,7 @@ export const getBlobPhotoUrlsCached: typeof getBlobPhotoUrls = (...args) =>
} }
)(); )();
export const getImageCacheHeadersForAuth = (session: AuthSession | null) => { export const getImageCacheHeadersForAuth = (session: Session | null) => {
return { return {
'Cache-Control': !session?.user 'Cache-Control': !session?.user
? 's-maxage=3600, stale-while-revalidate=59' ? 's-maxage=3600, stale-while-revalidate=59'

View File

@ -0,0 +1,25 @@
import { cc } from '@/utility/css';
import { BiErrorAlt } from 'react-icons/bi';
export default function ErrorNote({
children,
}: {
children: React.ReactNode
}) {
return (
<div className={cc(
'flex items-center gap-3',
'px-3 py-2 border',
'text-red-600 dark:text-red-500/90',
'bg-red-50/50 dark:bg-red-950/50',
'border-red-100 dark:border-red-950',
'rounded-md',
)}>
<BiErrorAlt
size={18}
className="text-red-600/80 dark:text-red-500/70"
/>
{children}
</div>
);
}

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { LegacyRef } from 'react'; import { LegacyRef } from 'react';
import { experimental_useFormStatus as useFormStatus } from 'react-dom'; // @ts-ignore
import { useFormStatus } from 'react-dom';
import Spinner from './Spinner'; import Spinner from './Spinner';
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';

View File

@ -4,9 +4,9 @@ import { blobToImage } from '@/utility/blob';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { CopyExif } from '@/lib/CopyExif'; import { CopyExif } from '@/lib/CopyExif';
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
import { AiOutlineCloudUpload } from 'react-icons/ai';
import Spinner from './Spinner'; import Spinner from './Spinner';
import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo'; import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { FiUploadCloud } from 'react-icons/fi';
const INPUT_ID = 'file'; const INPUT_ID = 'file';
@ -49,10 +49,10 @@ export default function ImageInput({
> >
<span className="w-4 inline-flex items-center"> <span className="w-4 inline-flex items-center">
{loading {loading
? <Spinner color="text" /> ? <Spinner color="text" className="translate-y-[0.5px]" />
: <AiOutlineCloudUpload : <FiUploadCloud
size={18} size={17}
className="translate-y-[0.5px]" className="translate-y-[0.5px] shrink-0"
/>} />}
</span> </span>
Upload Photo Upload Photo

View File

@ -1,17 +1,20 @@
'use client'; 'use client';
import { HTMLProps } from 'react'; import { HTMLProps } from 'react';
import { experimental_useFormStatus as useFormStatus } from 'react-dom'; // @ts-ignore
import { useFormStatus } from 'react-dom';
import Spinner from './Spinner'; import Spinner from './Spinner';
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
interface Props extends HTMLProps<HTMLButtonElement> { interface Props extends HTMLProps<HTMLButtonElement> {
icon?: JSX.Element icon?: JSX.Element
styleAsLink?: boolean
} }
export default function SubmitButtonWithStatus(props: Props) { export default function SubmitButtonWithStatus(props: Props) {
const { const {
icon, icon,
styleAsLink,
children, children,
disabled, disabled,
className, className,
@ -28,14 +31,17 @@ export default function SubmitButtonWithStatus(props: Props) {
className={cc( className={cc(
className, className,
'inline-flex items-center gap-2', 'inline-flex items-center gap-2',
styleAsLink && 'link',
)} )}
{...buttonProps} {...buttonProps}
> >
{(icon || pending) && {(icon || pending) &&
<span className={cc( <span className={cc(
'h-4',
'min-w-[1rem]', 'min-w-[1rem]',
'inline-flex justify-center sm:justify-normal', 'inline-flex justify-center sm:justify-normal',
'-mx-0.5', '-mx-0.5',
'translate-y-[1px]',
)}> )}>
{pending {pending
? <Spinner size={14} /> ? <Spinner size={14} />

View File

@ -2,22 +2,23 @@
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
import Link from 'next/link'; import Link from 'next/link';
import { useSession, signOut } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import ThemeSwitcher from '@/site/ThemeSwitcher'; import ThemeSwitcher from '@/site/ThemeSwitcher';
import SiteGrid from '../components/SiteGrid'; import SiteGrid from '../components/SiteGrid';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { isPathSignIn } from '@/site/paths'; import { isPathSignIn } from '@/site/paths';
import { signOutAction } from '@/auth/action';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
const LINK_STYLE = cc( const LINK_STYLE = cc(
'cursor-pointer', 'cursor-pointer',
'hover:text-gray-600', 'hover:text-gray-300',
'hover:dark:text-gray-600',
); );
export default function FooterAuth() { export default function FooterAuth() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const hasState = status !== 'loading';
const path = usePathname(); const path = usePathname();
return ( return (
@ -27,27 +28,30 @@ export default function FooterAuth() {
'my-8', 'my-8',
'text-dim', 'text-dim',
)}> )}>
<div className="flex gap-x-4 gap-y-1 flex-wrap flex-grow"> <div className="flex gap-x-4 gap-y-1 flex-wrap items-center flex-grow">
{hasState {status === 'loading'
? <> ? <>Loading ...</>
{session?.user === undefined && : <>
<>Loading ...</>} {session?.user?.email && <div>
{session?.user.email && <> {session.user.email}
<div>{session.user.email}</div> </div>}
<div {status === 'authenticated' &&
onClick={() => signOut()} <form action={signOutAction}>
<SubmitButtonWithStatus
className={LINK_STYLE}
styleAsLink
>
Sign Out
</SubmitButtonWithStatus>
</form>}
{status === 'unauthenticated' &&
<Link
href="/sign-in"
className={LINK_STYLE} className={LINK_STYLE}
> >
Sign Out Sign In
</div> </Link>}
</>} </>}
</>
: <Link
href="/sign-in"
className={LINK_STYLE}
>
Sign In
</Link>}
</div> </div>
{!isPathSignIn(path) && <ThemeSwitcher />} {!isPathSignIn(path) && <ThemeSwitcher />}
</div>} </div>}

View File

@ -72,10 +72,11 @@
px-4 px-4
text-base text-base
shadow-sm shadow-sm
disabled:bg-gray-100 dark:disabled:bg-gray-900 disabled:cursor-not-allowed
active:bg-gray-100 dark:active:bg-gray-900 active:bg-gray-100 dark:active:bg-gray-900
hover:border-gray-300 dark:hover:border-gray-600 hover:border-gray-300 dark:hover:border-gray-600
hover:disabled:border-gray-200 disabled:cursor-not-allowed
disabled:bg-gray-100 dark:disabled:bg-gray-900
disabled:border-gray-200 disabled:dark:border-gray-700
} }
button.subtle, .button.subtle { button.subtle, .button.subtle {
@apply @apply
@ -97,6 +98,11 @@
@apply @apply
text-medium text-medium
} }
button.link {
@apply
p-0 min-h-0
border-none active:bg-transparent shadow-none
}
/* Toasts */ /* Toasts */
.toaster [data-sonner-toast] { .toaster [data-sonner-toast] {
@apply @apply