Merge pull request #200 from sambecker/one-click-upload

1-click Uploads
This commit is contained in:
Sam Becker 2025-02-27 23:36:12 -06:00 committed by GitHub
commit c81211eaf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 472 additions and 203 deletions

View File

@ -6,6 +6,7 @@ import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
import { getOutdatedPhotosCount } from '@/photo/db/query';
import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
export const maxDuration = 60;
@ -43,7 +44,8 @@ export default async function AdminPhotosPage() {
photos,
photosCount,
photosCountOutdated,
onLastPhotoUpload: async () => {
shouldResize: !PRESERVE_ORIGINAL_UPLOADS,
onLastUpload: async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');

View File

@ -4,6 +4,7 @@ import { clsx } from 'clsx/lite';
import {
BASE_URL,
DEFAULT_THEME,
PRESERVE_ORIGINAL_UPLOADS,
SITE_DESCRIPTION,
SITE_DOMAIN_OR_TITLE,
SITE_TITLE,
@ -19,6 +20,8 @@ import CommandK from '@/app/CommandK';
import SwrConfigClient from '@/state/SwrConfigClient';
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
import ShareModals from '@/share/ShareModals';
import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
import { revalidatePath } from 'next/cache';
import '../tailwind.css';
@ -88,6 +91,14 @@ export default function RootLayout({
'mb-12',
'space-y-5',
)}>
<AdminUploadPanel
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
onLastUpload={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
<AdminBatchEditPanel />
{children}
</div>

View File

@ -19,6 +19,8 @@ import { BiCheckCircle, BiImageAdd } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient';
import PhotoTagFieldset from './PhotoTagFieldset';
import DeleteUploadButton from './DeleteUploadButton';
import ResponsiveText from '@/components/primitives/ResponsiveText';
const UPLOAD_BATCH_SIZE = 4;
@ -140,7 +142,10 @@ export default function AdminAddAllUploads({
disabled={Boolean(tagErrorMessage) || isAddingComplete}
icon={isAddingComplete
? <BiCheckCircle size={18} className="translate-x-[1px]" />
: <BiImageAdd size={18} className="translate-x-[1px]" />
: <BiImageAdd
size={18}
className="translate-x-[1px] translate-y-[2px]"
/>
}
onClick={async () => {
// eslint-disable-next-line max-len
@ -178,6 +183,17 @@ export default function AdminAddAllUploads({
>
{buttonText}
</ProgressButton>
<DeleteUploadButton
urls={storageUrls}
onDelete={() => router.push(PATH_ADMIN_PHOTOS)}
className="w-full flex justify-center"
shouldRedirectToAdminPhotos
hideTextOnMobile={false}
>
<ResponsiveText shortText="Delete Uploads">
Delete All Uploads
</ResponsiveText>
</DeleteUploadButton>
</div>
</div>
</Container>

View File

@ -20,6 +20,7 @@ import { PiSignOutBold } from 'react-icons/pi';
import { signOutAction } from '@/auth/actions';
import { ComponentProps } from 'react';
import { FaRegFolderOpen } from 'react-icons/fa';
import { FiUploadCloud } from 'react-icons/fi';
export default function AdminAppMenu({
className,
@ -33,6 +34,7 @@ export default function AdminAppMenu({
uploadsCount,
tagsCount,
selectedPhotoIds,
startUpload,
setSelectedPhotoIds,
refreshAdminData,
clearAuthStateAndRedirect,
@ -41,6 +43,13 @@ export default function AdminAppMenu({
const isSelecting = selectedPhotoIds !== undefined;
const items: ComponentProps<typeof MoreMenu>['items'] = [{
label: 'Upload Photos …',
icon: <FiUploadCloud
size={15}
className="translate-x-[0.5px] translate-y-[0.5px]"
/>,
action: startUpload,
}, {
label: 'Manage Photos',
...photosCount !== undefined && {
annotation: `${photosCount}`,

View File

@ -139,7 +139,7 @@ export default function AdminBatchEditPanelClient({
</LoaderButton>
</>}
<LoaderButton
icon={<IoCloseSharp size={20} className="translate-y-[0.5px]" />}
icon={<IoCloseSharp size={19} />}
onClick={() => setSelectedPhotoIds?.(undefined)}
/>
</>;
@ -155,7 +155,7 @@ export default function AdminBatchEditPanelClient({
<Note
color="gray"
className={clsx(
'min-h-[3.5rem]',
'min-h-[3.5rem] pr-2',
'backdrop-blur-lg border-transparent!',
'text-gray-900! dark:text-gray-100!',
'bg-gray-100/90! dark:bg-gray-900/70!',

View File

@ -1,24 +1,28 @@
'use client';
import PhotoUpload from '@/photo/PhotoUpload';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import { useAppState } from '@/state/AppState';
import Link from 'next/link';
import { useState } from 'react';
import { FaArrowRight } from 'react-icons/fa';
export default function AdminCTA() {
export default function AdminCTA({
shouldResize,
onLastUpload,
}: {
shouldResize: boolean
onLastUpload: () => Promise<void>
}) {
const { isUserSignedIn } = useAppState();
const [isUploading, setIsUploading] = useState(false);
return (
<div className="flex justify-center pt-4">
{isUserSignedIn
? <PhotoUpload
showUploadStatus={false}
isUploading={isUploading}
setIsUploading={setIsUploading}
? <PhotoUploadWithStatus
inputId="admin-cta"
shouldResize={shouldResize}
onLastUpload={onLastUpload}
showStatusText={false}
/>
: <Link
href={PATH_ADMIN_PHOTOS}

View File

@ -1,11 +1,9 @@
'use client';
import PhotoUpload from '@/photo/PhotoUpload';
import { clsx } from 'clsx/lite';
import SiteGrid from '@/components/SiteGrid';
import {
AI_TEXT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
@ -13,17 +11,19 @@ import PathLoaderButton from '@/components/primitives/PathLoaderButton';
import { PATH_ADMIN_OUTDATED } from '@/app/paths';
import { Photo } from '@/photo';
import { StorageListResponse } from '@/platforms/storage';
import { useState } from 'react';
import { LiaBroomSolid } from 'react-icons/lia';
import AdminUploadsTable from './AdminUploadsTable';
import { Timezone } from '@/utility/timezone';
import { useAppState } from '@/state/AppState';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
export default function AdminPhotosClient({
photos,
photosCount,
photosCountOutdated,
onLastPhotoUpload,
blobPhotoUrls,
shouldResize,
onLastUpload,
infiniteScrollInitial,
infiniteScrollMultiple,
timezone,
@ -31,25 +31,25 @@ export default function AdminPhotosClient({
photos: Photo[]
photosCount: number
photosCountOutdated: number
onLastPhotoUpload: () => Promise<void>
blobPhotoUrls: StorageListResponse
shouldResize: boolean
onLastUpload: () => Promise<void>
infiniteScrollInitial: number
infiniteScrollMultiple: number
timezone: Timezone
}) {
const [isUploading, setIsUploading] = useState(false);
const { uploadState: { isUploading } } = useAppState();
return (
<SiteGrid
contentMain={
<div>
<div className="flex">
<div className="flex gap-4 space-y-4">
<div className="grow min-w-0">
<PhotoUpload
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
isUploading={isUploading}
setIsUploading={setIsUploading}
onLastUpload={onLastPhotoUpload}
<PhotoUploadWithStatus
inputId="admin-photos"
shouldResize={shouldResize}
onLastUpload={onLastUpload}
/>
</div>
{photosCountOutdated > 0 && <PathLoaderButton

View File

@ -9,7 +9,7 @@ import { pathForAdminUploadUrl } from '@/app/paths';
import AddButton from './AddButton';
import { UrlAddStatus } from './AdminUploadsClient';
import ResponsiveDate from '@/components/ResponsiveDate';
import DeleteBlobButton from './DeleteBlobButton';
import DeleteBlobButton from './DeleteUploadButton';
export default function AdminUploadsTable({
isAdding,
@ -87,7 +87,7 @@ export default function AdminUploadsTable({
: <>
<AddButton path={pathForAdminUploadUrl(url)} />
<DeleteBlobButton
url={url}
urls={[url]}
shouldRedirectToAdminPhotos={urlAddStatuses.length <= 1}
onDelete={() => setUrlAddStatuses?.(urlAddStatuses.filter(
({ url: urlToRemove }) => urlToRemove !== url,

View File

@ -14,7 +14,7 @@ export default function DeleteButton({
icon={<BiTrash size={16} />}
spinnerColor="text"
className={clsx(
'text-red-500! dark:text-red-600!',
'text-red-500! dark:text-red-500!',
'active:bg-red-100/50! dark:active:bg-red-950/50!',
'disabled:bg-red-100/50! dark:disabled:bg-red-950/50!',
'border-red-200! hover:border-red-300!',

View File

@ -1,19 +1,25 @@
'use client';
import { deleteUploadAction } from '@/photo/actions';
import { deleteUploadsAction } from '@/photo/actions';
import DeleteButton from './DeleteButton';
import { useRouter } from 'next/navigation';
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import { useState } from 'react';
import { ReactNode, useState } from 'react';
export default function DeleteUploadButton({
url,
urls,
shouldRedirectToAdminPhotos,
onDelete,
hideTextOnMobile,
children,
className,
}: {
url: string
urls: string[]
shouldRedirectToAdminPhotos?: boolean
onDelete?: () => void
hideTextOnMobile?: boolean
children?: ReactNode
className?: string
}) {
const router = useRouter();
@ -21,10 +27,13 @@ export default function DeleteUploadButton({
return (
<DeleteButton
confirmText="Are you sure you want to delete this upload?"
className={className}
confirmText={urls.length === 1
? 'Are you sure you want to delete this upload?'
: `Are you sure you want to delete all ${urls.length} uploads?`}
onClick={() => {
setIsDeleting(true);
deleteUploadAction(url)
deleteUploadsAction(urls)
.then(() => {
onDelete?.();
if (shouldRedirectToAdminPhotos) {
@ -36,6 +45,9 @@ export default function DeleteUploadButton({
.catch(() => setIsDeleting(false));
}}
isLoading={isDeleting}
/>
hideTextOnMobile={hideTextOnMobile}
>
{children}
</DeleteButton>
);
}

View File

@ -0,0 +1,56 @@
'use client';
import Container from '@/components/Container';
import LoaderButton from '@/components/primitives/LoaderButton';
import SiteGrid from '@/components/SiteGrid';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { useAppState } from '@/state/AppState';
import clsx from 'clsx';
import { IoCloseSharp } from 'react-icons/io5';
export default function AdminUploadPanel({
shouldResize,
onLastUpload,
}: {
shouldResize: boolean
onLastUpload: () => Promise<void>
}) {
const {
uploadInputRef,
uploadState: {
isUploading,
hideUploadPanel,
},
resetUploadState,
} = useAppState();
return (
<SiteGrid
className={clsx((!isUploading || hideUploadPanel) && 'hidden')}
contentMain={
<Container
color="gray"
padding="tight"
className="p-2! pl-4! text-main!"
>
<div className="flex w-full items-center gap-2">
<PhotoUploadWithStatus
className="overflow-hidden w-full"
inputId="admin-upload-panel"
inputRef={uploadInputRef}
shouldResize={shouldResize}
onLastUpload={onLastUpload}
showButton={false}
/>
<LoaderButton
icon={<IoCloseSharp
size={18}
className="translate-y-[0.5px]"
/>}
onClick={resetUploadState}
/>
</div>
</Container>}
/>
);
}

19
src/admin/upload/index.ts Normal file
View File

@ -0,0 +1,19 @@
export interface UploadState {
isUploading: boolean
uploadError: string
debugDownload?: { href: string, fileName: string }
image?: HTMLImageElement
hideUploadPanel?: boolean
fileUploadName: string
fileUploadIndex: number
filesLength: number
}
export const INITIAL_UPLOAD_STATE: UploadState = {
isUploading: false,
uploadError: '',
hideUploadPanel: false,
fileUploadName: '',
fileUploadIndex: 0,
filesLength: 0,
};

View File

@ -226,6 +226,9 @@ export const isPathAdmin = (pathname?: string) =>
export const isPathTopLevelAdmin = (pathname?: string) =>
PATHS_ADMIN.some(path => path === pathname);
export const isPathAdminPhotos = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_PHOTOS);
export const isPathAdminInsights = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_INSIGHTS);

View File

@ -1,7 +1,7 @@
'use client';
import { blobToImage } from '@/utility/blob';
import { useRef, useState } from 'react';
import { useRef, RefObject } from 'react';
import { CopyExif } from '@/lib/CopyExif';
import exifr from 'exifr';
import { clsx } from 'clsx/lite';
@ -9,19 +9,22 @@ import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { FiUploadCloud } from 'react-icons/fi';
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
import ProgressButton from './primitives/ProgressButton';
const INPUT_ID = 'file';
import { useAppState } from '@/state/AppState';
export default function ImageInput({
ref: inputRefExternal,
id = 'file',
onStart,
onBlobReady,
shouldResize,
maxSize = MAX_IMAGE_SIZE,
quality = 0.8,
loading,
showUploadStatus = true,
showButton,
disabled: disabledProp,
debug,
}: {
ref?: RefObject<HTMLInputElement | null>
id?: string
onStart?: () => void
onBlobReady?: (args: {
blob: Blob,
@ -32,31 +35,42 @@ export default function ImageInput({
shouldResize?: boolean
maxSize?: number
quality?: number
loading?: boolean
showUploadStatus?: boolean
showButton?: boolean
disabled?: boolean
debug?: boolean
}) {
const inputRef = useRef<HTMLInputElement>(null);
const inputRefInternal = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [image, setImage] = useState<HTMLImageElement>();
const [filesLength, setFilesLength] = useState(0);
const [fileUploadIndex, setFileUploadIndex] = useState(0);
const [fileUploadName, setFileUploadName] = useState('');
const inputRef = inputRefExternal ?? inputRefInternal;
const {
uploadState: {
isUploading,
image,
filesLength,
fileUploadIndex,
},
setUploadState,
resetUploadState,
} = useAppState();
const disabled = disabledProp || isUploading;
return (
<div className="space-y-4 min-w-0">
<div className="flex flex-col gap-4 min-w-0">
<div className="flex items-center gap-2 sm:gap-4">
<label
htmlFor={INPUT_ID}
htmlFor={id}
className={clsx(
'shrink-0 select-none text-main',
loading && 'pointer-events-none cursor-not-allowed',
disabled && 'pointer-events-none cursor-not-allowed',
)}
>
{showButton &&
<ProgressButton
type="button"
isLoading={loading}
isLoading={disabled}
progress={filesLength > 1
? (fileUploadIndex + 1) / filesLength * 0.95
: undefined}
@ -64,34 +78,36 @@ export default function ImageInput({
size={18}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>}
aria-disabled={loading}
aria-disabled={disabled}
onClick={() => inputRef.current?.click()}
hideTextOnMobile={false}
primary
>
{loading
{isUploading
? filesLength > 1
? `Uploading ${fileUploadIndex + 1} of ${filesLength}`
: 'Uploading'
: 'Upload Photos'}
</ProgressButton>
</ProgressButton>}
<input
ref={inputRef}
id={INPUT_ID}
id={id}
type="file"
className="hidden!"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
disabled={loading}
disabled={disabled}
multiple
onChange={async e => {
onStart?.();
const { files } = e.currentTarget;
if (files && files.length > 0) {
setFilesLength(files.length);
setUploadState?.({ filesLength: files.length });
for (let i = 0; i < files.length; i++) {
const file = files[i];
setFileUploadIndex(i);
setFileUploadName(file.name);
setUploadState?.({
fileUploadIndex: i,
fileUploadName: file.name,
});
const callbackArgs = {
extension: file.name.split('.').pop()?.toLowerCase(),
hasMultipleUploads: files.length > 1,
@ -111,7 +127,7 @@ export default function ImageInput({
// Process images that need resizing
const image = await blobToImage(file);
setImage(image);
setUploadState?.({ image });
ctx.save();
@ -217,14 +233,12 @@ export default function ImageInput({
});
}
}
} else {
resetUploadState?.();
}
}}
/>
</label>
{showUploadStatus && filesLength > 0 &&
<div className="max-w-full truncate">
{fileUploadName}
</div>}
</div>
<canvas
ref={canvasRef}

View File

@ -72,9 +72,12 @@ export default function MoreMenuItem({
setIsLoading(false);
dismissMenu?.();
});
} else {
dismissMenu?.();
}
}
if (href && href !== pathname) {
if (href) {
if (href !== pathname) {
if (hrefDownloadName) {
setIsLoading(true);
downloadFileFromBrowser(href, hrefDownloadName)
@ -86,6 +89,9 @@ export default function MoreMenuItem({
setTransitionDidStart(true);
startTransition(() => router.push(href));
}
} else {
dismissMenu?.();
}
}
}}
>

View File

@ -1,102 +0,0 @@
'use client';
import { useState } from 'react';
import { uploadPhotoFromClient } from '@/platforms/storage';
import { useRouter } from 'next/navigation';
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
import ImageInput from '../components/ImageInput';
import { clsx } from 'clsx/lite';
export default function PhotoUpload({
shouldResize,
onLastUpload,
isUploading,
setIsUploading,
showUploadStatus,
debug,
}: {
shouldResize?: boolean
onLastUpload?: () => Promise<void>
isUploading: boolean
setIsUploading: (isUploading: boolean) => void
showUploadStatus?: boolean
debug?: boolean
}) {
const [uploadError, setUploadError] = useState<string>();
const [debugDownload, setDebugDownload] = useState<{
href: string
fileName: string
}>();
const router = useRouter();
return (
<div className={clsx(
'space-y-4',
isUploading && 'cursor-not-allowed',
)}>
<div className="flex items-center gap-8">
<form className="flex items-center min-w-0">
<ImageInput
loading={isUploading}
shouldResize={shouldResize}
onStart={() => {
setIsUploading(true);
setUploadError('');
}}
onBlobReady={async ({
blob,
extension,
hasMultipleUploads,
isLastBlob,
}) => {
if (debug) {
setDebugDownload({
href: URL.createObjectURL(blob),
fileName: `debug.${extension}`,
});
setIsUploading(false);
setUploadError('');
} else {
return uploadPhotoFromClient(
blob,
extension,
)
.then(async url => {
if (isLastBlob) {
await onLastUpload?.();
if (hasMultipleUploads) {
// Redirect to view multiple uploads
router.push(PATH_ADMIN_UPLOADS);
} else {
// Redirect to photo detail page
router.push(pathForAdminUploadUrl(url));
}
}
})
.catch(error => {
setIsUploading(false);
setUploadError(`Upload Error: ${error.message}`);
});
}
}}
showUploadStatus={showUploadStatus}
debug={debug}
/>
</form>
</div>
{debug && debugDownload &&
<a
className="block"
href={debugDownload.href}
download={debugDownload.fileName}
>
Download
</a>}
{uploadError &&
<div className="text-error">
{uploadError}
</div>}
</div>
);
};

View File

@ -0,0 +1,171 @@
'use client';
import { uploadPhotoFromClient } from '@/platforms/storage';
import { useRouter } from 'next/navigation';
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
import ImageInput from '../components/ImageInput';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
import { RefObject, useTransition } from 'react';
import { useRef } from 'react';
import { useEffect } from 'react';
import Spinner from '@/components/Spinner';
export default function PhotoUploadWithStatus({
inputRef,
inputId,
shouldResize,
onLastUpload,
showStatusText = true,
showButton = true,
className,
debug,
}: {
inputRef?: RefObject<HTMLInputElement | null>
inputId: string
shouldResize: boolean
onLastUpload?: () => Promise<void>
showStatusText?: boolean
showButton?: boolean
className?: string
debug?: boolean
}) {
const {
uploadState: {
isUploading,
uploadError,
fileUploadName,
fileUploadIndex,
filesLength,
debugDownload,
},
setUploadState,
resetUploadState,
} = useAppState();
const router = useRouter();
useEffect(() => {
// Hide upload panel while button is shown
if (showButton) {
setUploadState?.({ hideUploadPanel: true });
return () => { setUploadState?.({ hideUploadPanel: false }); };
}
}, [setUploadState, showButton]);
const shouldResetUploadStateAfterPending = useRef(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!isPending && shouldResetUploadStateAfterPending.current) {
resetUploadState?.();
shouldResetUploadStateAfterPending.current = false;
}
return () => {
if (shouldResetUploadStateAfterPending.current) {
resetUploadState?.();
}
};
}, [isPending, resetUploadState]);
const isFinishing = isPending && shouldResetUploadStateAfterPending.current;
return (
<div className={clsx(
'flex items-center gap-4',
isUploading && 'cursor-not-allowed',
className,
)}>
<div className={clsx(
showButton ? 'flex' : 'hidden',
'items-center',
)}>
<ImageInput
ref={inputRef}
id={inputId}
shouldResize={shouldResize}
disabled={isPending}
onStart={() => {
setUploadState?.({
isUploading: true,
uploadError: '',
});
}}
onBlobReady={async ({
blob,
extension,
hasMultipleUploads,
isLastBlob,
}) => {
if (debug) {
setUploadState?.({
isUploading: false,
uploadError: '',
debugDownload: {
href: URL.createObjectURL(blob),
fileName: `debug.${extension}`,
},
});
} else {
return uploadPhotoFromClient(
blob,
extension,
)
.then(async url => {
if (isLastBlob) {
await onLastUpload?.();
shouldResetUploadStateAfterPending.current = true;
startTransition(() => hasMultipleUploads
? router.push(PATH_ADMIN_UPLOADS)
: router.push(pathForAdminUploadUrl(url)));
}
})
.catch(error => {
setUploadState?.({
isUploading: false,
uploadError: `Upload Error: ${error.message}`,
});
});
}
}}
showButton={showButton}
debug={debug}
/>
</div>
{showStatusText && <div className={clsx(
'flex items-center gap-4',
'truncate',
)}>
{isUploading && !showButton &&
<Spinner
className="text-dim translate-y-[1px]"
color="text"
size={14}
/>}
<span className="truncate">
{isUploading
? isFinishing
? <>
Finishing
</>
: <>
{!showButton &&
`Uploading ${fileUploadIndex + 1} of ${filesLength}: `}
{fileUploadName}
</>
: !showButton && <>Initializing</>}
</span>
</div>}
{debug && debugDownload &&
<a
className="block"
href={debugDownload.href}
download={debugDownload.fileName}
>
Download
</a>}
{uploadError &&
<div className="text-error">
{uploadError}
</div>}
</div>
);
};

View File

@ -1,12 +1,13 @@
import AdminCTA from '@/admin/AdminCTA';
import Container from '@/components/Container';
import SiteGrid from '@/components/SiteGrid';
import { IS_SITE_READY } from '@/app/config';
import { IS_SITE_READY, PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
import { PATH_ADMIN_CONFIGURATION } from '@/app/paths';
import AdminAppConfiguration from '@/admin/AdminAppConfiguration';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { HiOutlinePhotograph } from 'react-icons/hi';
import { revalidatePath } from 'next/cache';
export default function PhotosEmptyState() {
return (
@ -33,7 +34,14 @@ export default function PhotosEmptyState() {
<div>
Add your first photo:
</div>
<AdminCTA />
<AdminCTA
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
onLastUpload={async () => {
'use server';
// Update upload count in admin nav
revalidatePath('/admin', 'layout');
}}
/>
</div>
<div>
Change the name of this blog and other configuration

View File

@ -290,9 +290,9 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
}
});
export const deleteUploadAction = async (url: string) =>
export const deleteUploadsAction = async (urls: string[]) =>
runAuthenticatedAdminServerAction(async () => {
await deleteFile(url);
await Promise.all(urls.map(url => deleteFile(url)));
revalidateAdminPaths();
});

View File

@ -1,7 +1,14 @@
import { Dispatch, SetStateAction, createContext, useContext } from 'react';
import {
Dispatch,
SetStateAction,
createContext,
useContext,
RefObject,
} from 'react';
import { AnimationConfig } from '@/components/AnimateItems';
import { ShareModalProps } from '@/share';
import { InsightIndicatorStatus } from '@/admin/insights';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
export interface AppStateContext {
// CORE
@ -15,6 +22,12 @@ export interface AppStateContext {
clearNextPhotoAnimation?: () => void
shouldRespondToKeyboardCommands?: boolean
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
// UPLOAD
startUpload?: () => void
uploadInputRef?: RefObject<HTMLInputElement | null>
uploadState: UploadState
setUploadState?: (uploadState: Partial<UploadState>) => void
resetUploadState?: () => void
// MODAL
isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
@ -57,6 +70,8 @@ export interface AppStateContext {
setShouldDebugRecipeOverlays?: Dispatch<SetStateAction<boolean>>
}
export const AppStateContext = createContext<AppStateContext>({});
export const AppStateContext = createContext<AppStateContext>({
uploadState: INITIAL_UPLOAD_STATE,
});
export const useAppState = () => useContext(AppStateContext);

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, ReactNode, useCallback } from 'react';
import { useState, useEffect, ReactNode, useCallback, useRef } from 'react';
import { AppStateContext } from './AppState';
import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames';
@ -23,16 +23,17 @@ import {
} from '@/auth/client';
import { useRouter } from 'next/navigation';
import { PATH_SIGN_IN } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
export default function AppStateProvider({
children,
}: {
children: ReactNode
}) {
const { previousPathname } = usePathnames();
const router = useRouter();
const { previousPathname } = usePathnames();
// CORE
const [hasLoaded, setHasLoaded] =
useState(false);
@ -42,12 +43,16 @@ export default function AppStateProvider({
useState<AnimationConfig>();
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
useState(true);
// UPLOAD
const uploadInputRef = useRef<HTMLInputElement>(null);
const [uploadState, _setUploadState] =
useState(INITIAL_UPLOAD_STATE);
// MODAL
const [isCommandKOpen, setIsCommandKOpen] =
useState(false);
const [shareModalProps, setShareModalProps] =
useState<ShareModalProps>();
// ADMIN
// AUTH
const [userEmail, setUserEmail] =
useState<string>();
const [isUserSignedInEager, setIsUserSignedInEager] =
@ -85,6 +90,19 @@ export default function AppStateProvider({
const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] =
useState(false);
const startUpload = useCallback(() => {
if (uploadInputRef.current) {
uploadInputRef.current.value = '';
uploadInputRef.current.click();
}
}, []);
const setUploadState = useCallback((uploadState: Partial<UploadState>) => {
_setUploadState(prev => ({ ...prev, ...uploadState }));
}, []);
const resetUploadState = useCallback(() => {
_setUploadState(INITIAL_UPLOAD_STATE);
}, []);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const { data: auth, error: authError } = useSWR('getAuth', getAuthAction);
@ -136,6 +154,7 @@ export default function AppStateProvider({
const clearAuthStateAndRedirect = useCallback((shouldRedirect = true) => {
setUserEmail(undefined);
setIsUserSignedInEager(false);
clearAuthEmailCookie();
if (shouldRedirect) { router.push(PATH_SIGN_IN); }
}, [router]);
@ -154,6 +173,12 @@ export default function AppStateProvider({
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
shouldRespondToKeyboardCommands,
setShouldRespondToKeyboardCommands,
// UPLOAD
uploadInputRef,
startUpload,
uploadState,
setUploadState,
resetUploadState,
// MODAL
isCommandKOpen,
setIsCommandKOpen,