Finalize base multi-origin upload approach
This commit is contained in:
parent
83188b7190
commit
1a273625a9
@ -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');
|
||||
|
||||
@ -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,
|
||||
@ -20,6 +21,7 @@ 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';
|
||||
|
||||
@ -89,7 +91,14 @@ export default function RootLayout({
|
||||
'mb-12',
|
||||
'space-y-5',
|
||||
)}>
|
||||
<AdminUploadPanel />
|
||||
<AdminUploadPanel
|
||||
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||
onLastUpload={async () => {
|
||||
'use server';
|
||||
// Update upload count in admin nav
|
||||
revalidatePath('/admin', 'layout');
|
||||
}}
|
||||
/>
|
||||
<AdminBatchEditPanel />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,18 +1,29 @@
|
||||
'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 { FaArrowRight } from 'react-icons/fa';
|
||||
|
||||
export default function AdminCTA() {
|
||||
export default function AdminCTA({
|
||||
shouldResize,
|
||||
onLastUpload,
|
||||
}: {
|
||||
shouldResize: boolean
|
||||
onLastUpload: () => Promise<void>
|
||||
}) {
|
||||
const { isUserSignedIn } = useAppState();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center pt-4">
|
||||
{isUserSignedIn
|
||||
? <PhotoUpload showUploadStatus={false} />
|
||||
? <PhotoUploadWithStatus
|
||||
inputId="admin-cta"
|
||||
shouldResize={shouldResize}
|
||||
onLastUpload={onLastUpload}
|
||||
showStatusText={false}
|
||||
/>
|
||||
: <Link
|
||||
href={PATH_ADMIN_PHOTOS}
|
||||
className="button primary"
|
||||
|
||||
@ -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';
|
||||
@ -17,13 +15,15 @@ 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,8 +31,9 @@ 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
|
||||
@ -43,11 +44,12 @@ export default function AdminPhotosClient({
|
||||
<SiteGrid
|
||||
contentMain={
|
||||
<div>
|
||||
<div className="flex space-y-4">
|
||||
<div className="flex gap-4 space-y-4">
|
||||
<div className="grow min-w-0">
|
||||
<PhotoUpload
|
||||
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||
onLastUpload={onLastPhotoUpload}
|
||||
<PhotoUploadWithStatus
|
||||
inputId="admin-photos"
|
||||
shouldResize={shouldResize}
|
||||
onLastUpload={onLastUpload}
|
||||
/>
|
||||
</div>
|
||||
{photosCountOutdated > 0 && <PathLoaderButton
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,61 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { pathForAdminUploadUrl } from '@/app/paths';
|
||||
import { PATH_ADMIN_UPLOADS } from '@/app/paths';
|
||||
import { isPathAdminPhotos } from '@/app/paths';
|
||||
import Container from '@/components/Container';
|
||||
import ImageInput from '@/components/ImageInput';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { uploadPhotoFromClient } from '@/platforms/storage';
|
||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useTransition } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
|
||||
export default function AdminUploadPanel({
|
||||
shouldResize,
|
||||
onLastUpload,
|
||||
debug,
|
||||
}: {
|
||||
shouldResize: boolean
|
||||
onLastUpload?: () => Promise<void>
|
||||
debug?: boolean
|
||||
onLastUpload: () => Promise<void>
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
uploadInputRef,
|
||||
uploadState: {
|
||||
isUploading,
|
||||
filesLength,
|
||||
fileUploadIndex,
|
||||
fileUploadName,
|
||||
uploadError,
|
||||
debugDownload,
|
||||
hideUploadPanel,
|
||||
},
|
||||
setUploadState,
|
||||
resetUploadState,
|
||||
} = useAppState();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const shouldResetUploadStateAfterPending = useRef(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
useEffect(() => {
|
||||
if (!isPending) {
|
||||
if (shouldResetUploadStateAfterPending.current) {
|
||||
resetUploadState?.();
|
||||
shouldResetUploadStateAfterPending.current = false;
|
||||
}
|
||||
}
|
||||
}, [isPending, resetUploadState]);
|
||||
const isFinalizing = isPending &&
|
||||
shouldResetUploadStateAfterPending.current;
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
className={clsx((!isUploading || hideUploadPanel) && 'hidden')}
|
||||
className={clsx((
|
||||
!isUploading ||
|
||||
isPathAdminPhotos(pathname)
|
||||
) && 'hidden')}
|
||||
contentMain={
|
||||
<Container
|
||||
color="gray"
|
||||
@ -63,93 +40,22 @@ export default function AdminUploadPanel({
|
||||
className="p-2! pl-4! text-main!"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="grow">
|
||||
<ImageInput
|
||||
ref={uploadInputRef}
|
||||
<PhotoUploadWithStatus
|
||||
className="overflow-hidden w-full"
|
||||
inputId="admin-upload-panel"
|
||||
inputRef={uploadInputRef}
|
||||
shouldResize={shouldResize}
|
||||
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}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}}
|
||||
showUploadStatus={false}
|
||||
showUploadButton={false}
|
||||
onLastUpload={onLastUpload}
|
||||
showButton={false}
|
||||
/>
|
||||
{isUploading
|
||||
? <div className={clsx('flex items-center gap-4')}>
|
||||
{isFinalizing
|
||||
? <span>
|
||||
Finishing
|
||||
</span>
|
||||
: <span className="inline-block truncate">
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName}
|
||||
</span>}
|
||||
<Spinner
|
||||
className="text-dim translate-y-[1px]"
|
||||
color="text"
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
: 'Initializing...'}
|
||||
</div>
|
||||
<LoaderButton
|
||||
icon={<IoCloseSharp
|
||||
size={18}
|
||||
className="translate-y-[0.5px]"
|
||||
/>}
|
||||
onClick={resetUploadState}
|
||||
/>
|
||||
</div>
|
||||
{debug && debugDownload &&
|
||||
<a
|
||||
className="block"
|
||||
href={debugDownload.href}
|
||||
download={debugDownload.fileName}
|
||||
>
|
||||
Download
|
||||
</a>}
|
||||
{uploadError &&
|
||||
<div className="text-error">
|
||||
{uploadError}
|
||||
</div>}
|
||||
</Container>}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -3,17 +3,15 @@ export interface UploadState {
|
||||
uploadError: string
|
||||
debugDownload?: { href: string, fileName: string }
|
||||
image?: HTMLImageElement
|
||||
filesLength: number
|
||||
fileUploadIndex: number
|
||||
fileUploadName: string
|
||||
hideUploadPanel: boolean
|
||||
fileUploadIndex: number
|
||||
filesLength: number
|
||||
}
|
||||
|
||||
export const INITIAL_UPLOAD_STATE: UploadState = {
|
||||
isUploading: false,
|
||||
uploadError: '',
|
||||
fileUploadName: '',
|
||||
filesLength: 0,
|
||||
fileUploadIndex: 0,
|
||||
hideUploadPanel: false,
|
||||
filesLength: 0,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -11,20 +11,20 @@ import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
|
||||
import ProgressButton from './primitives/ProgressButton';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
|
||||
const INPUT_ID = 'file';
|
||||
|
||||
export default function ImageInput({
|
||||
ref,
|
||||
ref: inputRefExternal,
|
||||
id = 'file',
|
||||
onStart,
|
||||
onBlobReady,
|
||||
shouldResize,
|
||||
maxSize = MAX_IMAGE_SIZE,
|
||||
quality = 0.8,
|
||||
showUploadButton = true,
|
||||
showUploadStatus = true,
|
||||
showButton,
|
||||
disabled: disabledProp,
|
||||
debug,
|
||||
}: {
|
||||
ref?: RefObject<HTMLInputElement | null>
|
||||
id?: string
|
||||
onStart?: () => void
|
||||
onBlobReady?: (args: {
|
||||
blob: Blob,
|
||||
@ -35,38 +35,42 @@ export default function ImageInput({
|
||||
shouldResize?: boolean
|
||||
maxSize?: number
|
||||
quality?: number
|
||||
showUploadButton?: 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 inputRef = inputRefExternal ?? inputRefInternal;
|
||||
|
||||
const {
|
||||
uploadState: {
|
||||
isUploading,
|
||||
image,
|
||||
filesLength,
|
||||
fileUploadIndex,
|
||||
fileUploadName,
|
||||
},
|
||||
setUploadState,
|
||||
resetUploadState,
|
||||
} = useAppState();
|
||||
|
||||
const disabled = disabledProp || isUploading;
|
||||
|
||||
return (
|
||||
<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',
|
||||
isUploading && 'pointer-events-none cursor-not-allowed',
|
||||
disabled && 'pointer-events-none cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{showUploadButton &&
|
||||
{showButton &&
|
||||
<ProgressButton
|
||||
type="button"
|
||||
isLoading={isUploading}
|
||||
isLoading={disabled}
|
||||
progress={filesLength > 1
|
||||
? (fileUploadIndex + 1) / filesLength * 0.95
|
||||
: undefined}
|
||||
@ -74,7 +78,7 @@ export default function ImageInput({
|
||||
size={18}
|
||||
className="translate-x-[-0.5px] translate-y-[0.5px]"
|
||||
/>}
|
||||
aria-disabled={isUploading}
|
||||
aria-disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
hideTextOnMobile={false}
|
||||
primary
|
||||
@ -86,12 +90,12 @@ export default function ImageInput({
|
||||
: 'Upload Photos'}
|
||||
</ProgressButton>}
|
||||
<input
|
||||
ref={ref ?? inputRef}
|
||||
id={INPUT_ID}
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="file"
|
||||
className="hidden!"
|
||||
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
|
||||
disabled={isUploading}
|
||||
disabled={disabled}
|
||||
multiple
|
||||
onChange={async e => {
|
||||
onStart?.();
|
||||
@ -229,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}
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
'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';
|
||||
|
||||
export default function PhotoUpload({
|
||||
shouldResize,
|
||||
onLastUpload,
|
||||
showUploadStatus,
|
||||
debug,
|
||||
}: {
|
||||
shouldResize?: boolean
|
||||
onLastUpload?: () => Promise<void>
|
||||
showUploadStatus?: boolean
|
||||
debug?: boolean
|
||||
}) {
|
||||
const {
|
||||
uploadState: {
|
||||
isUploading,
|
||||
uploadError,
|
||||
debugDownload,
|
||||
},
|
||||
setUploadState,
|
||||
resetUploadState,
|
||||
} = useAppState();
|
||||
|
||||
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
|
||||
shouldResize={shouldResize}
|
||||
onStart={() => {
|
||||
setUploadState?.({
|
||||
isUploading: true,
|
||||
uploadError: '',
|
||||
hideUploadPanel: true,
|
||||
});
|
||||
}}
|
||||
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?.();
|
||||
resetUploadState?.();
|
||||
if (hasMultipleUploads) {
|
||||
// Redirect to view multiple uploads
|
||||
router.push(PATH_ADMIN_UPLOADS);
|
||||
} else {
|
||||
// Redirect to photo detail page
|
||||
router.push(pathForAdminUploadUrl(url));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
setUploadState?.({
|
||||
isUploading: false,
|
||||
uploadError: `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>
|
||||
);
|
||||
};
|
||||
163
src/photo/PhotoUploadWithStatus.tsx
Normal file
163
src/photo/PhotoUploadWithStatus.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'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();
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -30,10 +30,10 @@ export default function AppStateProvider({
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { previousPathname } = usePathnames();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { previousPathname } = usePathnames();
|
||||
|
||||
// CORE
|
||||
const [hasLoaded, setHasLoaded] =
|
||||
useState(false);
|
||||
@ -91,7 +91,10 @@ export default function AppStateProvider({
|
||||
useState(false);
|
||||
|
||||
const startUpload = useCallback(() => {
|
||||
uploadInputRef.current?.click();
|
||||
if (uploadInputRef.current) {
|
||||
uploadInputRef.current.value = '';
|
||||
uploadInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
const setUploadState = useCallback((uploadState: Partial<UploadState>) => {
|
||||
_setUploadState(prev => ({ ...prev, ...uploadState }));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user