Merge branch 'main' into refresh-exif
This commit is contained in:
commit
bf78ced898
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -19,6 +19,7 @@
|
|||||||
"qaub",
|
"qaub",
|
||||||
"QRSTUVWXYZ",
|
"QRSTUVWXYZ",
|
||||||
"Reala",
|
"Reala",
|
||||||
|
"CredentialsSignin",
|
||||||
"skippable",
|
"skippable",
|
||||||
"sonner",
|
"sonner",
|
||||||
"thephotoblog",
|
"thephotoblog",
|
||||||
|
|||||||
11
README.md
11
README.md
@ -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).
|
||||||
|
|||||||
@ -19,9 +19,6 @@ const nextConfig = {
|
|||||||
}],
|
}],
|
||||||
minimumCacheTTL: 31536000,
|
minimumCacheTTL: 31536000,
|
||||||
},
|
},
|
||||||
experimental: {
|
|
||||||
serverActions: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withBundleAnalyzer(nextConfig);
|
module.exports = withBundleAnalyzer(nextConfig);
|
||||||
|
|||||||
26
package.json
26
package.json
@ -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
423
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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>;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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
21
src/auth/action.ts
Normal 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();
|
||||||
|
};
|
||||||
@ -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
4
src/cache/index.ts
vendored
@ -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'
|
||||||
|
|||||||
25
src/components/ErrorNote.tsx
Normal file
25
src/components/ErrorNote.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user