mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Adding uploading backup feature.
This commit is contained in:
+2
@@ -14,6 +14,7 @@ import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/ret
|
|||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
|
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -107,6 +108,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
<CronButton database={dbItem}/>
|
<CronButton database={dbItem}/>
|
||||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||||
organizationId={organization.id}/>
|
organizationId={organization.id}/>
|
||||||
|
<ImportModal database={dbItem}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||||
|
|||||||
@@ -0,0 +1,850 @@
|
|||||||
|
"use client"
|
||||||
|
import {cn} from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Accept,
|
||||||
|
FileRejection,
|
||||||
|
useDropzone as rootUseDropzone,
|
||||||
|
} from "react-dropzone";
|
||||||
|
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||||
|
|
||||||
|
type DropzoneResult<TUploadRes, TUploadError> =
|
||||||
|
| {
|
||||||
|
status: "pending";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
error: TUploadError;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "success";
|
||||||
|
result: TUploadRes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileStatus<TUploadRes, TUploadError> = {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
file: File;
|
||||||
|
tries: number;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
status: "pending";
|
||||||
|
result?: undefined;
|
||||||
|
error?: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
error: TUploadError;
|
||||||
|
result?: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "success";
|
||||||
|
result: TUploadRes;
|
||||||
|
error?: undefined;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileStatusReducer = <TUploadRes, TUploadError>(
|
||||||
|
state: FileStatus<TUploadRes, TUploadError>[],
|
||||||
|
action:
|
||||||
|
| {
|
||||||
|
type: "add";
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "remove";
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
| ({
|
||||||
|
type: "update-status";
|
||||||
|
id: string;
|
||||||
|
} & DropzoneResult<TUploadRes, TUploadError>),
|
||||||
|
): FileStatus<TUploadRes, TUploadError>[] => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "add":
|
||||||
|
return [
|
||||||
|
...state,
|
||||||
|
{
|
||||||
|
id: action.id,
|
||||||
|
fileName: action.fileName,
|
||||||
|
file: action.file,
|
||||||
|
status: "pending",
|
||||||
|
tries: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "remove":
|
||||||
|
return state.filter((fileStatus) => fileStatus.id !== action.id);
|
||||||
|
case "update-status":
|
||||||
|
return state.map((fileStatus) => {
|
||||||
|
if (fileStatus.id === action.id) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const {id, type, ...rest} = action;
|
||||||
|
return {
|
||||||
|
...fileStatus,
|
||||||
|
...rest,
|
||||||
|
tries:
|
||||||
|
action.status === "pending"
|
||||||
|
? fileStatus.tries + 1
|
||||||
|
: fileStatus.tries,
|
||||||
|
} as FileStatus<TUploadRes, TUploadError>;
|
||||||
|
}
|
||||||
|
return fileStatus;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
|
||||||
|
const dropZoneErrorCodes = [
|
||||||
|
"file-invalid-type",
|
||||||
|
"file-too-large",
|
||||||
|
"file-too-small",
|
||||||
|
"too-many-files",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
|
||||||
|
const errors = fileRejections.map((rejection) => {
|
||||||
|
return rejection.errors
|
||||||
|
.filter((error) =>
|
||||||
|
dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
|
||||||
|
)
|
||||||
|
.map((error) => error.code) as DropZoneErrorCode[];
|
||||||
|
});
|
||||||
|
return Array.from(new Set(errors.flat()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRootError = (
|
||||||
|
errorCodes: DropZoneErrorCode[],
|
||||||
|
limits: {
|
||||||
|
accept?: Accept;
|
||||||
|
maxSize?: number;
|
||||||
|
minSize?: number;
|
||||||
|
maxFiles?: number;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const errors = errorCodes.map((error) => {
|
||||||
|
switch (error) {
|
||||||
|
case "file-invalid-type":
|
||||||
|
const acceptedTypes = Object.values(limits.accept ?? {})
|
||||||
|
.flat()
|
||||||
|
.join(", ");
|
||||||
|
return `only ${acceptedTypes} are allowed`;
|
||||||
|
case "file-too-large":
|
||||||
|
const maxMb = limits.maxSize
|
||||||
|
? (limits.maxSize / (1024 * 1024)).toFixed(2)
|
||||||
|
: "infinite?";
|
||||||
|
return `max size is ${maxMb}MB`;
|
||||||
|
case "file-too-small":
|
||||||
|
const roundedMinSize = limits.minSize
|
||||||
|
? (limits.minSize / (1024 * 1024)).toFixed(2)
|
||||||
|
: "negative?";
|
||||||
|
return `min size is ${roundedMinSize}MB`;
|
||||||
|
case "too-many-files":
|
||||||
|
return `max ${limits.maxFiles} files`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const joinedErrors = errors.join(", ");
|
||||||
|
return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseDropzoneProps<TUploadRes, TUploadError> = {
|
||||||
|
onDropFile: (
|
||||||
|
file: File,
|
||||||
|
) => Promise<
|
||||||
|
Exclude<DropzoneResult<TUploadRes, TUploadError>, { status: "pending" }>
|
||||||
|
>;
|
||||||
|
onRemoveFile?: (id: string) => void | Promise<void>;
|
||||||
|
onFileUploaded?: (result: TUploadRes) => void;
|
||||||
|
onFileUploadError?: (error: TUploadError) => void;
|
||||||
|
onAllUploaded?: () => void;
|
||||||
|
onRootError?: (error: string | undefined) => void;
|
||||||
|
maxRetryCount?: number;
|
||||||
|
autoRetry?: boolean;
|
||||||
|
validation?: {
|
||||||
|
accept?: Accept;
|
||||||
|
minSize?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
maxFiles?: number;
|
||||||
|
};
|
||||||
|
shiftOnMaxFiles?: boolean;
|
||||||
|
} & (TUploadError extends string
|
||||||
|
? {
|
||||||
|
shapeUploadError?: (error: TUploadError) => string | void;
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
shapeUploadError: (error: TUploadError) => string | void;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||||
|
getRootProps: ReturnType<typeof rootUseDropzone>["getRootProps"];
|
||||||
|
getInputProps: ReturnType<typeof rootUseDropzone>["getInputProps"];
|
||||||
|
onRemoveFile: (id: string) => Promise<void>;
|
||||||
|
onRetry: (id: string) => Promise<void>;
|
||||||
|
canRetry: (id: string) => boolean;
|
||||||
|
fileStatuses: FileStatus<TUploadRes, TUploadError>[];
|
||||||
|
isInvalid: boolean;
|
||||||
|
isDragActive: boolean;
|
||||||
|
rootError: string | undefined;
|
||||||
|
inputId: string;
|
||||||
|
rootMessageId: string;
|
||||||
|
rootDescriptionId: string;
|
||||||
|
getFileMessageId: (id: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDropzone = <TUploadRes, TUploadError = string>(
|
||||||
|
props: UseDropzoneProps<TUploadRes, TUploadError>,
|
||||||
|
): UseDropzoneReturn<TUploadRes, TUploadError> => {
|
||||||
|
const {
|
||||||
|
onDropFile: pOnDropFile,
|
||||||
|
onRemoveFile: pOnRemoveFile,
|
||||||
|
shapeUploadError: pShapeUploadError,
|
||||||
|
onFileUploaded: pOnFileUploaded,
|
||||||
|
onFileUploadError: pOnFileUploadError,
|
||||||
|
onAllUploaded: pOnAllUploaded,
|
||||||
|
onRootError: pOnRootError,
|
||||||
|
maxRetryCount,
|
||||||
|
autoRetry,
|
||||||
|
validation,
|
||||||
|
shiftOnMaxFiles,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inputId = useId();
|
||||||
|
const rootMessageId = `${inputId}-root-message`;
|
||||||
|
const rootDescriptionId = `${inputId}-description`;
|
||||||
|
const [rootError, _setRootError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const setRootError = useCallback(
|
||||||
|
(error: string | undefined) => {
|
||||||
|
_setRootError(error);
|
||||||
|
if (pOnRootError !== undefined) {
|
||||||
|
pOnRootError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pOnRootError, _setRootError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
|
||||||
|
|
||||||
|
const isInvalid = useMemo(() => {
|
||||||
|
return (
|
||||||
|
fileStatuses.filter((file) => file.status === "error").length > 0 ||
|
||||||
|
rootError !== undefined
|
||||||
|
);
|
||||||
|
}, [fileStatuses, rootError]);
|
||||||
|
|
||||||
|
const _uploadFile = useCallback(
|
||||||
|
async (file: File, id: string, tries = 0) => {
|
||||||
|
const result = await pOnDropFile(file);
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
|
||||||
|
dispatch({type: "update-status", id, status: "pending"});
|
||||||
|
return _uploadFile(file, id, tries + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "update-status",
|
||||||
|
id,
|
||||||
|
status: "error",
|
||||||
|
error:
|
||||||
|
pShapeUploadError !== undefined
|
||||||
|
? pShapeUploadError(result.error)
|
||||||
|
: result.error,
|
||||||
|
});
|
||||||
|
if (pOnFileUploadError !== undefined) {
|
||||||
|
pOnFileUploadError(result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pOnFileUploaded !== undefined) {
|
||||||
|
pOnFileUploaded(result.result);
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: "update-status",
|
||||||
|
id,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
autoRetry,
|
||||||
|
maxRetryCount,
|
||||||
|
pOnDropFile,
|
||||||
|
pShapeUploadError,
|
||||||
|
pOnFileUploadError,
|
||||||
|
pOnFileUploaded,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRemoveFile = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await pOnRemoveFile?.(id);
|
||||||
|
dispatch({type: "remove", id});
|
||||||
|
},
|
||||||
|
[pOnRemoveFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canRetry = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||||
|
return (
|
||||||
|
fileStatus?.status === "error" &&
|
||||||
|
fileStatus.tries < (maxRetryCount ?? Infinity)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[fileStatuses, maxRetryCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRetry = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!canRetry(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({type: "update-status", id, status: "pending"});
|
||||||
|
const fileStatus = fileStatuses.find((file) => file.id === id);
|
||||||
|
if (!fileStatus || fileStatus.status !== "error") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _uploadFile(fileStatus.file, id);
|
||||||
|
},
|
||||||
|
[canRetry, fileStatuses, _uploadFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
|
||||||
|
|
||||||
|
const dropzone = rootUseDropzone({
|
||||||
|
accept: validation?.accept,
|
||||||
|
minSize: validation?.minSize,
|
||||||
|
maxSize: validation?.maxSize,
|
||||||
|
onDropAccepted: async (newFiles) => {
|
||||||
|
setRootError(undefined);
|
||||||
|
|
||||||
|
// useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
|
||||||
|
const fileCount = fileStatuses.length;
|
||||||
|
const maxNewFiles =
|
||||||
|
validation?.maxFiles === undefined
|
||||||
|
? Infinity
|
||||||
|
: validation?.maxFiles - fileCount;
|
||||||
|
|
||||||
|
if (maxNewFiles < newFiles.length) {
|
||||||
|
if (shiftOnMaxFiles === true) {
|
||||||
|
} else {
|
||||||
|
setRootError(getRootError(["too-many-files"], validation ?? {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slicedNewFiles =
|
||||||
|
shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
|
||||||
|
|
||||||
|
const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
|
||||||
|
if (fileCount + 1 > maxNewFiles) {
|
||||||
|
await onRemoveFile(fileStatuses[index].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
dispatch({type: "add", fileName: file.name, file, id});
|
||||||
|
await _uploadFile(file, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(onDropFilePromises);
|
||||||
|
if (pOnAllUploaded !== undefined) {
|
||||||
|
pOnAllUploaded();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDropRejected: (fileRejections) => {
|
||||||
|
const errorMessage = getRootError(
|
||||||
|
getDropZoneErrorCodes(fileRejections),
|
||||||
|
validation ?? {}
|
||||||
|
);
|
||||||
|
setRootError(errorMessage);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRootProps: dropzone.getRootProps,
|
||||||
|
getInputProps: dropzone.getInputProps,
|
||||||
|
inputId,
|
||||||
|
rootMessageId,
|
||||||
|
rootDescriptionId,
|
||||||
|
getFileMessageId,
|
||||||
|
onRemoveFile,
|
||||||
|
onRetry,
|
||||||
|
canRetry,
|
||||||
|
fileStatuses: fileStatuses as FileStatus<TUploadRes, TUploadError>[],
|
||||||
|
isInvalid,
|
||||||
|
rootError,
|
||||||
|
isDragActive: dropzone.isDragActive,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const DropZoneContext = createContext<UseDropzoneReturn<any, any>>({
|
||||||
|
getRootProps: () => ({}) as never,
|
||||||
|
getInputProps: () => ({}) as never,
|
||||||
|
onRemoveFile: async () => {
|
||||||
|
},
|
||||||
|
onRetry: async () => {
|
||||||
|
},
|
||||||
|
canRetry: () => false,
|
||||||
|
fileStatuses: [],
|
||||||
|
isInvalid: false,
|
||||||
|
isDragActive: false,
|
||||||
|
rootError: undefined,
|
||||||
|
inputId: "",
|
||||||
|
rootMessageId: "",
|
||||||
|
rootDescriptionId: "",
|
||||||
|
getFileMessageId: () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const useDropzoneContext = <TUploadRes, TUploadError>() => {
|
||||||
|
return useContext(DropZoneContext) as UseDropzoneReturn<
|
||||||
|
TUploadRes,
|
||||||
|
TUploadError
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DropzoneProps<TUploadRes, TUploadError>
|
||||||
|
extends UseDropzoneReturn<TUploadRes, TUploadError> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropzone = <TUploadRes, TUploadError>(
|
||||||
|
props: DropzoneProps<TUploadRes, TUploadError>,
|
||||||
|
) => {
|
||||||
|
const {children, ...rest} = props;
|
||||||
|
return (
|
||||||
|
<DropZoneContext.Provider value={rest}>{children}</DropZoneContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Dropzone.displayName = "Dropzone";
|
||||||
|
|
||||||
|
interface DropZoneAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropZoneArea = forwardRef<HTMLDivElement, DropZoneAreaProps>(
|
||||||
|
({className, children, ...props}, forwardedRef) => {
|
||||||
|
const context = useDropzoneContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("DropzoneArea must be used within a Dropzone");
|
||||||
|
}
|
||||||
|
|
||||||
|
const {onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref} =
|
||||||
|
context.getRootProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
// A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
|
<div
|
||||||
|
ref={(instance) => {
|
||||||
|
// TODO: test if this actually works?
|
||||||
|
ref.current = instance;
|
||||||
|
if (typeof forwardedRef === "function") {
|
||||||
|
forwardedRef(instance);
|
||||||
|
} else if (forwardedRef) {
|
||||||
|
forwardedRef.current = instance;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
{...props}
|
||||||
|
aria-label="dropzone"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
context.isDragActive && "animate-pulse bg-black/5",
|
||||||
|
context.isInvalid && "border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
DropZoneArea.displayName = "DropZoneArea";
|
||||||
|
|
||||||
|
export interface DropzoneDescriptionProps
|
||||||
|
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneDescription = forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
DropzoneDescriptionProps
|
||||||
|
>((props, ref) => {
|
||||||
|
const {className, ...rest} = props;
|
||||||
|
const context = useDropzoneContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("DropzoneDescription must be used within a Dropzone");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={context.rootDescriptionId}
|
||||||
|
{...rest}
|
||||||
|
className={cn("pb-1 text-sm text-muted-foreground", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DropzoneDescription.displayName = "DropzoneDescription";
|
||||||
|
|
||||||
|
interface DropzoneFileListContext<TUploadRes, TUploadError> {
|
||||||
|
onRemoveFile: () => Promise<void>;
|
||||||
|
onRetry: () => Promise<void>;
|
||||||
|
fileStatus: FileStatus<TUploadRes, TUploadError>;
|
||||||
|
canRetry: boolean;
|
||||||
|
dropzoneId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneFileListContext = createContext<
|
||||||
|
DropzoneFileListContext<unknown, unknown>
|
||||||
|
>({
|
||||||
|
onRemoveFile: async () => {
|
||||||
|
},
|
||||||
|
onRetry: async () => {
|
||||||
|
},
|
||||||
|
fileStatus: {} as FileStatus<unknown, unknown>,
|
||||||
|
canRetry: false,
|
||||||
|
dropzoneId: "",
|
||||||
|
messageId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const useDropzoneFileListContext = () => {
|
||||||
|
return useContext(DropzoneFileListContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DropZoneFileListProps
|
||||||
|
extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneFileList = forwardRef<HTMLOListElement, DropZoneFileListProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const context = useDropzoneContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("DropzoneFileList must be used within a Dropzone");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
aria-label="dropzone-file-list"
|
||||||
|
{...props}
|
||||||
|
className={cn("flex flex-col gap-4", props.className)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
DropzoneFileList.displayName = "DropzoneFileList";
|
||||||
|
|
||||||
|
interface DropzoneFileListItemProps<TUploadRes, TUploadError>
|
||||||
|
extends React.LiHTMLAttributes<HTMLLIElement> {
|
||||||
|
file: FileStatus<TUploadRes, TUploadError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneFileListItem = forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
DropzoneFileListItemProps<unknown, unknown>
|
||||||
|
>(({className, ...props}, ref) => {
|
||||||
|
const fileId = props.file.id;
|
||||||
|
const {
|
||||||
|
onRemoveFile: cOnRemoveFile,
|
||||||
|
onRetry: cOnRetry,
|
||||||
|
getFileMessageId: cGetFileMessageId,
|
||||||
|
canRetry: cCanRetry,
|
||||||
|
inputId: cInputId,
|
||||||
|
} = useDropzoneContext();
|
||||||
|
|
||||||
|
const onRemoveFile = useCallback(
|
||||||
|
() => cOnRemoveFile(fileId),
|
||||||
|
[fileId, cOnRemoveFile],
|
||||||
|
);
|
||||||
|
const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
|
||||||
|
const messageId = cGetFileMessageId(fileId);
|
||||||
|
const isInvalid = props.file.status === "error";
|
||||||
|
const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
|
||||||
|
return (
|
||||||
|
<DropzoneFileListContext.Provider
|
||||||
|
value={{
|
||||||
|
onRemoveFile,
|
||||||
|
onRetry,
|
||||||
|
fileStatus: props.file,
|
||||||
|
canRetry,
|
||||||
|
dropzoneId: cInputId,
|
||||||
|
messageId,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
aria-label="dropzone-file-list-item"
|
||||||
|
aria-describedby={isInvalid ? messageId : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col justify-center gap-2 rounded-md bg-muted/40 px-4 py-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</li>
|
||||||
|
</DropzoneFileListContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DropzoneFileListItem.displayName = "DropzoneFileListItem";
|
||||||
|
|
||||||
|
interface DropzoneFileMessageProps
|
||||||
|
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneFileMessage = forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
DropzoneFileMessageProps
|
||||||
|
>((props, ref) => {
|
||||||
|
const {children, ...rest} = props;
|
||||||
|
const context = useDropzoneFileListContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"DropzoneFileMessage must be used within a DropzoneFileListItem",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body =
|
||||||
|
context.fileStatus.status === "error"
|
||||||
|
? String(context.fileStatus.error)
|
||||||
|
: children;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={context.messageId}
|
||||||
|
{...rest}
|
||||||
|
className={cn(
|
||||||
|
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||||
|
rest.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DropzoneFileMessage.displayName = "DropzoneFileMessage";
|
||||||
|
|
||||||
|
interface DropzoneMessageProps
|
||||||
|
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneMessage = forwardRef<HTMLParagraphElement, DropzoneMessageProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {children, ...rest} = props;
|
||||||
|
const context = useDropzoneContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("DropzoneRootMessage must be used within a Dropzone");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = context.rootError ? String(context.rootError) : children;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={context.rootMessageId}
|
||||||
|
{...rest}
|
||||||
|
className={cn(
|
||||||
|
"h-5 text-[0.8rem] font-medium text-destructive",
|
||||||
|
rest.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
DropzoneMessage.displayName = "DropzoneMessage";
|
||||||
|
|
||||||
|
interface DropzoneRemoveFileProps extends ButtonVariantsProps {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneRemoveFile = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
DropzoneRemoveFileProps
|
||||||
|
>(({className, ...props}, ref) => {
|
||||||
|
const context = useDropzoneFileListContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"DropzoneRemoveFile must be used within a DropzoneFileListItem",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
onClick={context.onRemoveFile}
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<span className="sr-only">Remove file</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
|
||||||
|
|
||||||
|
interface DropzoneRetryFileProps extends ButtonVariantsProps {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneRetryFile = forwardRef<HTMLButtonElement, DropzoneRetryFileProps>(
|
||||||
|
({className, ...props}, ref) => {
|
||||||
|
const context = useDropzoneFileListContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"DropzoneRetryFile must be used within a DropzoneFileListItem",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRetry = context.canRetry;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
aria-disabled={!canRetry}
|
||||||
|
aria-label="retry"
|
||||||
|
onClick={context.onRetry}
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<span className="sr-only">Retry</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
DropzoneRetryFile.displayName = "DropzoneRetryFile";
|
||||||
|
|
||||||
|
interface DropzoneTriggerProps
|
||||||
|
extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropzoneTrigger = forwardRef<HTMLLabelElement, DropzoneTriggerProps>(
|
||||||
|
({className, children, ...props}, ref) => {
|
||||||
|
const context = useDropzoneContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("DropzoneTrigger must be used within a Dropzone");
|
||||||
|
}
|
||||||
|
|
||||||
|
const {fileStatuses, getFileMessageId} = context;
|
||||||
|
|
||||||
|
const fileMessageIds = useMemo(
|
||||||
|
() =>
|
||||||
|
fileStatuses
|
||||||
|
.filter((file) => file.status === "error")
|
||||||
|
.map((file) => getFileMessageId(file.id)),
|
||||||
|
[fileStatuses, getFileMessageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded-sm bg-secondary px-4 py-2 font-medium ring-offset-background transition-colors focus-within:outline-none hover:bg-secondary/80 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<input
|
||||||
|
{...context.getInputProps({
|
||||||
|
style: {
|
||||||
|
display: undefined,
|
||||||
|
},
|
||||||
|
className: "sr-only",
|
||||||
|
tabIndex: undefined,
|
||||||
|
})}
|
||||||
|
aria-describedby={
|
||||||
|
context.isInvalid
|
||||||
|
? [context.rootMessageId, ...fileMessageIds].join(" ")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-invalid={context.isInvalid}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
DropzoneTrigger.displayName = "DropzoneTrigger";
|
||||||
|
|
||||||
|
interface InfiniteProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
status: "pending" | "success" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueTextMap = {
|
||||||
|
pending: "indeterminate",
|
||||||
|
success: "100%",
|
||||||
|
error: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfiniteProgress = forwardRef<HTMLDivElement, InfiniteProgressProps>(
|
||||||
|
({className, ...props}, ref) => {
|
||||||
|
const done = props.status === "success" || props.status === "error";
|
||||||
|
const error = props.status === "error";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuetext={valueTextMap[props.status]}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
// TODO: add proper done transition
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full rounded-full bg-primary",
|
||||||
|
done ? "translate-x-0" : "animate-infinite-progress",
|
||||||
|
error && "bg-destructive",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
InfiniteProgress.displayName = "InfiniteProgress";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dropzone,
|
||||||
|
DropZoneArea,
|
||||||
|
DropzoneDescription,
|
||||||
|
DropzoneFileList,
|
||||||
|
DropzoneFileListItem,
|
||||||
|
DropzoneFileMessage,
|
||||||
|
DropzoneMessage,
|
||||||
|
DropzoneRemoveFile,
|
||||||
|
DropzoneRetryFile,
|
||||||
|
DropzoneTrigger,
|
||||||
|
InfiniteProgress,
|
||||||
|
useDropzone,
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
CloudUploadIcon,
|
||||||
|
Loader2,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropZoneArea,
|
||||||
|
DropzoneDescription,
|
||||||
|
DropzoneFileList,
|
||||||
|
DropzoneFileListItem,
|
||||||
|
DropzoneMessage,
|
||||||
|
DropzoneRemoveFile,
|
||||||
|
DropzoneTrigger,
|
||||||
|
useDropzone,
|
||||||
|
Dropzone,
|
||||||
|
} from "@/components/ui/dropzone";
|
||||||
|
|
||||||
|
type DropZoneFileProps = {
|
||||||
|
onFileDropAction?: (file: File) => Promise<void> | void;
|
||||||
|
onFileRemoveAction?: (file: File) => void;
|
||||||
|
accept?: Record<string, string[]>;
|
||||||
|
maxSize?: number;
|
||||||
|
maxFiles?: number;
|
||||||
|
description?: string;
|
||||||
|
fileKind?: string;
|
||||||
|
dragMessage?: string;
|
||||||
|
fileList?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const DropZoneFile = ({
|
||||||
|
onFileDropAction,
|
||||||
|
onFileRemoveAction,
|
||||||
|
accept = {"application/pdf": [".pdf"]},
|
||||||
|
maxSize = 50 * 1024 * 1024,
|
||||||
|
maxFiles = 1,
|
||||||
|
description = "Please select a file",
|
||||||
|
fileKind = "Upload files (.pdf)",
|
||||||
|
dragMessage = "Click here or drag & drop to upload",
|
||||||
|
fileList = true
|
||||||
|
}: DropZoneFileProps) => {
|
||||||
|
const dropzone = useDropzone({
|
||||||
|
onDropFile: async (file: File) => {
|
||||||
|
onFileDropAction?.(file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
result: URL.createObjectURL(file),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
accept,
|
||||||
|
maxSize,
|
||||||
|
maxFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemove = (file: any) => {
|
||||||
|
dropzone.onRemoveFile(file.id);
|
||||||
|
onFileRemoveAction?.(file.file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="not-prose flex flex-col gap-4 h-full">
|
||||||
|
<Dropzone {...dropzone}>
|
||||||
|
<div className="h-full">
|
||||||
|
{dropzone.fileStatuses.length < maxFiles && (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<DropzoneDescription>{description}</DropzoneDescription>
|
||||||
|
<DropzoneMessage/>
|
||||||
|
</div>
|
||||||
|
<DropZoneArea className="flex-1 px-0 py-0 mt-2">
|
||||||
|
<DropzoneTrigger
|
||||||
|
className="flex h-full w-full flex-col items-center justify-center gap-4 bg-transparent p-10 text-center text-sm">
|
||||||
|
<CloudUploadIcon className="size-8"/>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{fileKind}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{dragMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropzoneTrigger>
|
||||||
|
</DropZoneArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{fileList && (
|
||||||
|
<DropzoneFileList className="grid gap-3 p-0">
|
||||||
|
{dropzone.fileStatuses.map((file) => (
|
||||||
|
<DropzoneFileListItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
className="overflow-hidden rounded-md bg-secondary p-0 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-2 pl-4">
|
||||||
|
<div className="flex overflow-hidden items-center gap-3">
|
||||||
|
<div>
|
||||||
|
{file.status === "pending" ? (
|
||||||
|
<Loader2 className="animate-spin" size={16}/>
|
||||||
|
) : file.status === "error" ? (
|
||||||
|
<CircleX className="text-red-600" size={16}/>
|
||||||
|
) : (
|
||||||
|
<CircleCheck className="text-green-700" size={16}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm flex">
|
||||||
|
<span>{file.fileName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(file.file.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropzoneRemoveFile
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 hover:outline"
|
||||||
|
// @ts-ignore
|
||||||
|
onClick={() => handleRemove(file)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4"/>
|
||||||
|
</DropzoneRemoveFile>
|
||||||
|
</div>
|
||||||
|
</DropzoneFileListItem>
|
||||||
|
))}
|
||||||
|
</DropzoneFileList>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Dropzone>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
import {Import} from "lucide-react";
|
||||||
|
import {UploadBackupZone} from "@/components/wrappers/dashboard/database/import/upload-backup-zone";
|
||||||
|
|
||||||
|
type ImportModalProps = {
|
||||||
|
database: DatabaseWith;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportModal = ({database}: ImportModalProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<Import/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Backup</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Import backup file of your choice.
|
||||||
|
</DialogDescription>
|
||||||
|
<Separator className="mt-3 "/>
|
||||||
|
<UploadBackupZone
|
||||||
|
onSuccessAction={() => setOpen(false)}
|
||||||
|
databaseId={database.id}/>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Loader2} from "lucide-react";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {uploadBackupAction} from "@/components/wrappers/dashboard/database/import/upload-backup.action";
|
||||||
|
import {Card, CardContent} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type UploadRetentionZoneProps = {
|
||||||
|
onSuccessAction?: () => void;
|
||||||
|
databaseId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const mutationUpload = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("databaseId", databaseId);
|
||||||
|
|
||||||
|
const result = await uploadBackupAction(formData)
|
||||||
|
|
||||||
|
const inner = result?.data;
|
||||||
|
if (inner?.success) {
|
||||||
|
toast.success(inner.actionSuccess?.message);
|
||||||
|
onSuccessAction?.()
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(inner?.actionError?.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("An error occurred while upload in the backup");
|
||||||
|
} finally {
|
||||||
|
router.refresh();
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const acceptDbImportFiles: Record<string, string[]> = {
|
||||||
|
"application/sql": [".sql"],
|
||||||
|
"application/x-sql": [".sql"],
|
||||||
|
"text/plain": [".sql"],
|
||||||
|
"application/octet-stream": [".dump"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isProcessing ? (
|
||||||
|
<UploadLoader label="Uploading database backup…"/>
|
||||||
|
) : (
|
||||||
|
<DropZoneFile
|
||||||
|
accept={acceptDbImportFiles}
|
||||||
|
maxSize={500 * 1024 * 1024}
|
||||||
|
maxFiles={1}
|
||||||
|
description="Import database backup"
|
||||||
|
fileKind="Database file (.sql, .dump)"
|
||||||
|
dragMessage="Click or drag a database dump here"
|
||||||
|
onFileDropAction={(file: File) => setFile(file)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!isProcessing && file) && (
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading
|
||||||
|
onClick={() => mutationUpload.mutateAsync(file)}
|
||||||
|
isPending={false}>
|
||||||
|
Upload
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const UploadLoader = ({label = "Processing uploading..."}: { label?: string }) => {
|
||||||
|
return (
|
||||||
|
<Card className="w-full border">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-muted-foreground">
|
||||||
|
<div className="relative">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin inset-0"/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Do not close this window
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use server"
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {Backup} from "@/db/schema/07_database";
|
||||||
|
import {getFileExtension} from "../../../../../../app/api/agent/[agentId]/backup/helpers";
|
||||||
|
import {v4 as uuidv4} from "uuid";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
|
||||||
|
|
||||||
|
export const uploadBackupAction = userAction
|
||||||
|
.schema(z.instanceof(FormData))
|
||||||
|
.action(async ({parsedInput: formData}): Promise<ServerActionResult<Backup>> => {
|
||||||
|
try {
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
const databaseId = formData.get("databaseId") as string;
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
alertPolicies: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Database does not exist",
|
||||||
|
status: 500,
|
||||||
|
cause: "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = getFileExtension(database.dbms)
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
const uuid = uuidv4();
|
||||||
|
const fileName = `imported_${uuid}${fileExtension}`;
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Settings not set",
|
||||||
|
status: 500,
|
||||||
|
cause: "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let success: boolean, message: string, filePath: string;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
settings.storage === "local"
|
||||||
|
? await uploadLocalPrivate(fileName, buffer)
|
||||||
|
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
||||||
|
|
||||||
|
({success, message, filePath} = result);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "An error has occurred while uploading file",
|
||||||
|
status: 500,
|
||||||
|
cause: "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [backup] = await db
|
||||||
|
.insert(drizzleDb.schemas.backup)
|
||||||
|
.values({
|
||||||
|
status: 'success',
|
||||||
|
databaseId: database.id,
|
||||||
|
file: fileName,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: backup,
|
||||||
|
actionSuccess: {
|
||||||
|
message: `Backup successfully imported`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to import backup.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -38,8 +38,6 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
|||||||
if (!props.backups) return [];
|
if (!props.backups) return [];
|
||||||
|
|
||||||
return props.backups.filter(backup => {
|
return props.backups.filter(backup => {
|
||||||
|
|
||||||
// --- Status Filter ---
|
|
||||||
if (selectedFilters.length > 0) {
|
if (selectedFilters.length > 0) {
|
||||||
const selectedValues = selectedFilters.map(f => f.value);
|
const selectedValues = selectedFilters.map(f => f.value);
|
||||||
const status = backup.deletedAt != null ? "deleted" : "available";
|
const status = backup.deletedAt != null ? "deleted" : "available";
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {cn} from "@/lib/utils";
|
|||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
import {formatLocalizedDate} from "@/utils/date-formatting";
|
import {formatLocalizedDate} from "@/utils/date-formatting";
|
||||||
|
import {isImportedFilename} from "@/utils/text";
|
||||||
|
|
||||||
|
|
||||||
export function backupColumns(
|
export function backupColumns(
|
||||||
@@ -63,6 +64,14 @@ export function backupColumns(
|
|||||||
{
|
{
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
header: "Reference",
|
header: "Reference",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const fileName = row.original.file
|
||||||
|
console.log(row.original)
|
||||||
|
const reference = row.original.id
|
||||||
|
const isImported = isImportedFilename(`${fileName}`)
|
||||||
|
console.log(isImported)
|
||||||
|
return isImported ? `${reference} (imported)` : `${reference}`
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
|
|||||||
@@ -11,4 +11,8 @@ export function capitalizeFirstLetter(text: string): string {
|
|||||||
|
|
||||||
export function isUUID(str: string) {
|
export function isUUID(str: string) {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str);
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImportedFilename(name: string): boolean {
|
||||||
|
return name.startsWith("imported_");
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user